@nativesquare/soma 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,791 +1,864 @@
1
- import type { SomaComponent } from "./index.js";
2
- import type { MutationCtx, SomaError } from "./types.js";
3
- import type {
4
- HKWorkout,
5
- HKCategorySample,
6
- HKQuantitySample,
7
- HKActivitySummary,
8
- HKCharacteristics,
9
- } from "../component/healthkit/types.js";
10
- import { transformWorkout } from "../component/healthkit/transform/activity.js";
11
- import { transformSleep } from "../component/healthkit/transform/sleep.js";
12
- import { transformBody } from "../component/healthkit/transform/body.js";
13
- import {
14
- transformDaily,
15
- transformDailyFromSummary,
16
- } from "../component/healthkit/transform/daily.js";
17
- import { transformNutrition } from "../component/healthkit/transform/nutrition.js";
18
- import { transformMenstruation } from "../component/healthkit/transform/menstruation.js";
19
- import { transformAthlete } from "../component/healthkit/transform/athlete.js";
20
-
21
- const PROVIDER = "APPLE";
22
-
23
- type SyncResult<T extends Record<string, number>> = {
24
- data: { synced: T };
25
- errors: SomaError[];
26
- };
27
-
28
- /**
29
- * Client class for Apple HealthKit integration with Soma.
30
- *
31
- * Unlike {@link import("./strava.js").SomaStrava | SomaStrava} and
32
- * {@link import("./garmin.js").SomaGarmin | SomaGarmin}, HealthKit is an
33
- * on-device provider data is queried locally on iOS, not fetched from a
34
- * cloud API. This class wraps the transform + ingest pipeline so the host
35
- * app gets the same ergonomic interface as cloud providers.
36
- *
37
- * No configuration is required HealthKit has no OAuth credentials.
38
- *
39
- * @example
40
- * ```ts
41
- * // Sync workouts received from the React Native client:
42
- * const result = await soma.healthkit.syncActivities(ctx, {
43
- * userId: "user_123",
44
- * workouts: hkWorkouts,
45
- * });
46
- * // result: { data: { synced: { activities: 5 } }, errors: [] }
47
- *
48
- * // Or sync everything at once:
49
- * await soma.healthkit.syncAll(ctx, {
50
- * userId: "user_123",
51
- * workouts: hkWorkouts,
52
- * sleepSessions: [nightSamples],
53
- * bodySamples: hkBodySamples,
54
- * });
55
- * ```
56
- */
57
- export class SomaHealthKit {
58
- constructor(private component: SomaComponent) { }
59
-
60
- // ─── Per-Type Sync Methods ─────────────────────────────────────────────
61
-
62
- /**
63
- * Sync workout activities from HealthKit.
64
- *
65
- * Transforms each `HKWorkout` into the Soma Activity schema and ingests it
66
- * with automatic deduplication by `workout.uuid`.
67
- *
68
- * @param ctx - Mutation (or action) context from the host app
69
- * @param args.userId - The host app's user identifier
70
- * @param args.workouts - Array of HKWorkout objects from HealthKit
71
- */
72
- async syncActivities(
73
- ctx: MutationCtx,
74
- args: { userId: string; workouts: HKWorkout[] },
75
- ): Promise<SyncResult<{ activities: number }>> {
76
- const connectionId = await this.resolveConnection(ctx, args.userId);
77
- const errors: SomaError[] = [];
78
- let count = 0;
79
-
80
- for (const workout of args.workouts) {
81
- try {
82
- const data = transformWorkout(workout);
83
- await ctx.runMutation(this.component.public.ingestActivity, {
84
- connectionId,
85
- userId: args.userId,
86
- ...data,
87
- });
88
- count++;
89
- } catch (err) {
90
- errors.push({
91
- type: "activity",
92
- id: workout.uuid,
93
- message: err instanceof Error ? err.message : String(err),
94
- });
95
- }
96
- }
97
-
98
- await this.updateLastDataUpdate(ctx, connectionId);
99
- return { data: { synced: { activities: count } }, errors };
100
- }
101
-
102
- /**
103
- * Sync sleep sessions from HealthKit.
104
- *
105
- * Each inner array represents one sleep session (all `HKCategorySample`
106
- * stage records for a single night). Each session is aggregated into a
107
- * single Soma Sleep document.
108
- *
109
- * @param ctx - Mutation (or action) context from the host app
110
- * @param args.userId - The host app's user identifier
111
- * @param args.sessions - Array of sessions, each an array of sleep-stage samples
112
- */
113
- async syncSleep(
114
- ctx: MutationCtx,
115
- args: { userId: string; sessions: HKCategorySample[][] },
116
- ): Promise<SyncResult<{ sleep: number }>> {
117
- const connectionId = await this.resolveConnection(ctx, args.userId);
118
- const errors: SomaError[] = [];
119
- let count = 0;
120
-
121
- for (const session of args.sessions) {
122
- const sessionId = session[0]?.uuid ?? "unknown";
123
- try {
124
- const data = transformSleep(session);
125
- await ctx.runMutation(this.component.public.ingestSleep, {
126
- connectionId,
127
- userId: args.userId,
128
- ...data,
129
- });
130
- count++;
131
- } catch (err) {
132
- errors.push({
133
- type: "sleep",
134
- id: sessionId,
135
- message: err instanceof Error ? err.message : String(err),
136
- });
137
- }
138
- }
139
-
140
- await this.updateLastDataUpdate(ctx, connectionId);
141
- return { data: { synced: { sleep: count } }, errors };
142
- }
143
-
144
- /**
145
- * Sync body metrics from HealthKit.
146
- *
147
- * Accepts a mixed array of body-related quantity samples (heart rate, HRV,
148
- * blood pressure, SpO2, weight, etc.) for a single time window and produces
149
- * one Soma Body document.
150
- *
151
- * @param ctx - Mutation (or action) context from the host app
152
- * @param args.userId - The host app's user identifier
153
- * @param args.samples - Array of HKQuantitySample for the desired time range
154
- * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
155
- */
156
- async syncBody(
157
- ctx: MutationCtx,
158
- args: {
159
- userId: string;
160
- samples: HKQuantitySample[];
161
- timeRange?: { start_time: string; end_time: string };
162
- },
163
- ): Promise<SyncResult<{ body: number }>> {
164
- const connectionId = await this.resolveConnection(ctx, args.userId);
165
- const errors: SomaError[] = [];
166
- let count = 0;
167
-
168
- try {
169
- const data = transformBody(args.samples, args.timeRange);
170
- await ctx.runMutation(this.component.public.ingestBody, {
171
- connectionId,
172
- userId: args.userId,
173
- ...data,
174
- });
175
- count++;
176
- } catch (err) {
177
- errors.push({
178
- type: "body",
179
- id: "transform",
180
- message: err instanceof Error ? err.message : String(err),
181
- });
182
- }
183
-
184
- await this.updateLastDataUpdate(ctx, connectionId);
185
- return { data: { synced: { body: count } }, errors };
186
- }
187
-
188
- /**
189
- * Sync daily activity data from HealthKit quantity samples.
190
- *
191
- * Accepts samples for a single day (steps, distance, energy, exercise time,
192
- * heart rate, etc.) and produces one Soma Daily document.
193
- *
194
- * @param ctx - Mutation (or action) context from the host app
195
- * @param args.userId - The host app's user identifier
196
- * @param args.samples - Array of HKQuantitySample for the desired day
197
- * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
198
- */
199
- async syncDaily(
200
- ctx: MutationCtx,
201
- args: {
202
- userId: string;
203
- samples: HKQuantitySample[];
204
- timeRange?: { start_time: string; end_time: string };
205
- },
206
- ): Promise<SyncResult<{ daily: number }>> {
207
- const connectionId = await this.resolveConnection(ctx, args.userId);
208
- const errors: SomaError[] = [];
209
- let count = 0;
210
-
211
- try {
212
- const data = transformDaily(args.samples, args.timeRange);
213
- await ctx.runMutation(this.component.public.ingestDaily, {
214
- connectionId,
215
- userId: args.userId,
216
- ...data,
217
- });
218
- count++;
219
- } catch (err) {
220
- errors.push({
221
- type: "daily",
222
- id: "transform",
223
- message: err instanceof Error ? err.message : String(err),
224
- });
225
- }
226
-
227
- await this.updateLastDataUpdate(ctx, connectionId);
228
- return { data: { synced: { daily: count } }, errors };
229
- }
230
-
231
- /**
232
- * Sync daily activity data from HealthKit activity ring summaries.
233
- *
234
- * Each `HKActivitySummary` represents one day's activity rings (Move,
235
- * Exercise, Stand). Each summary produces one Soma Daily document.
236
- *
237
- * @param ctx - Mutation (or action) context from the host app
238
- * @param args.userId - The host app's user identifier
239
- * @param args.summaries - Array of HKActivitySummary from HealthKit
240
- */
241
- async syncDailyFromSummary(
242
- ctx: MutationCtx,
243
- args: { userId: string; summaries: HKActivitySummary[] },
244
- ): Promise<SyncResult<{ daily: number }>> {
245
- const connectionId = await this.resolveConnection(ctx, args.userId);
246
- const errors: SomaError[] = [];
247
- let count = 0;
248
-
249
- for (const summary of args.summaries) {
250
- const { year, month, day } = summary.dateComponents;
251
- const summaryId = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
252
- try {
253
- const data = transformDailyFromSummary(summary);
254
- await ctx.runMutation(this.component.public.ingestDaily, {
255
- connectionId,
256
- userId: args.userId,
257
- ...data,
258
- });
259
- count++;
260
- } catch (err) {
261
- errors.push({
262
- type: "daily",
263
- id: summaryId,
264
- message: err instanceof Error ? err.message : String(err),
265
- });
266
- }
267
- }
268
-
269
- await this.updateLastDataUpdate(ctx, connectionId);
270
- return { data: { synced: { daily: count } }, errors };
271
- }
272
-
273
- /**
274
- * Sync nutrition data from HealthKit.
275
- *
276
- * Accepts dietary quantity samples for a single time window and produces
277
- * one Soma Nutrition document with macros and micros.
278
- *
279
- * @param ctx - Mutation (or action) context from the host app
280
- * @param args.userId - The host app's user identifier
281
- * @param args.samples - Array of HKQuantitySample with dietary type identifiers
282
- * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
283
- */
284
- async syncNutrition(
285
- ctx: MutationCtx,
286
- args: {
287
- userId: string;
288
- samples: HKQuantitySample[];
289
- timeRange?: { start_time: string; end_time: string };
290
- },
291
- ): Promise<SyncResult<{ nutrition: number }>> {
292
- const connectionId = await this.resolveConnection(ctx, args.userId);
293
- const errors: SomaError[] = [];
294
- let count = 0;
295
-
296
- try {
297
- const data = transformNutrition(args.samples, args.timeRange);
298
- await ctx.runMutation(this.component.public.ingestNutrition, {
299
- connectionId,
300
- userId: args.userId,
301
- ...data,
302
- });
303
- count++;
304
- } catch (err) {
305
- errors.push({
306
- type: "nutrition" as SomaError["type"],
307
- id: "transform",
308
- message: err instanceof Error ? err.message : String(err),
309
- });
310
- }
311
-
312
- await this.updateLastDataUpdate(ctx, connectionId);
313
- return { data: { synced: { nutrition: count } }, errors };
314
- }
315
-
316
- /**
317
- * Sync menstruation data from HealthKit.
318
- *
319
- * Accepts menstrual flow category samples for a single time window and
320
- * produces one Soma Menstruation document.
321
- *
322
- * @param ctx - Mutation (or action) context from the host app
323
- * @param args.userId - The host app's user identifier
324
- * @param args.samples - Array of HKCategorySample with menstrual flow values
325
- * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
326
- */
327
- async syncMenstruation(
328
- ctx: MutationCtx,
329
- args: {
330
- userId: string;
331
- samples: HKCategorySample[];
332
- timeRange?: { start_time: string; end_time: string };
333
- },
334
- ): Promise<SyncResult<{ menstruation: number }>> {
335
- const connectionId = await this.resolveConnection(ctx, args.userId);
336
- const errors: SomaError[] = [];
337
- let count = 0;
338
-
339
- try {
340
- const data = transformMenstruation(args.samples, args.timeRange);
341
- await ctx.runMutation(this.component.public.ingestMenstruation, {
342
- connectionId,
343
- userId: args.userId,
344
- ...data,
345
- });
346
- count++;
347
- } catch (err) {
348
- errors.push({
349
- type: "menstruation",
350
- id: "transform",
351
- message: err instanceof Error ? err.message : String(err),
352
- });
353
- }
354
-
355
- await this.updateLastDataUpdate(ctx, connectionId);
356
- return { data: { synced: { menstruation: count } }, errors };
357
- }
358
-
359
- /**
360
- * Sync athlete profile from HealthKit.
361
- *
362
- * HealthKit exposes limited profile data (biological sex, date of birth).
363
- * Produces one Soma Athlete document per connection.
364
- *
365
- * @param ctx - Mutation (or action) context from the host app
366
- * @param args.userId - The host app's user identifier
367
- * @param args.characteristics - The HKCharacteristics from HealthKit
368
- */
369
- async syncAthlete(
370
- ctx: MutationCtx,
371
- args: { userId: string; characteristics: HKCharacteristics },
372
- ): Promise<SyncResult<{ athletes: number }>> {
373
- const connectionId = await this.resolveConnection(ctx, args.userId);
374
- const errors: SomaError[] = [];
375
- let count = 0;
376
-
377
- try {
378
- const data = transformAthlete(args.characteristics);
379
- await ctx.runMutation(this.component.public.ingestAthlete, {
380
- connectionId,
381
- userId: args.userId,
382
- ...data,
383
- });
384
- count++;
385
- } catch (err) {
386
- errors.push({
387
- type: "athlete",
388
- id: "transform",
389
- message: err instanceof Error ? err.message : String(err),
390
- });
391
- }
392
-
393
- await this.updateLastDataUpdate(ctx, connectionId);
394
- return { data: { synced: { athletes: count } }, errors };
395
- }
396
-
397
- // ─── Orchestrator ────────────────────────────────────────────────────────
398
-
399
- /**
400
- * Sync all provided HealthKit data types in a single call.
401
- *
402
- * Only data types with values provided are synced. Errors from individual
403
- * data types are collected — one failing type does not block others.
404
- *
405
- * @param ctx - Mutation (or action) context from the host app
406
- * @param args.userId - The host app's user identifier
407
- * @param args.workouts - Optional array of HKWorkout objects
408
- * @param args.sleepSessions - Optional array of sleep sessions (each an array of stage samples)
409
- * @param args.bodySamples - Optional body-related quantity samples
410
- * @param args.dailySamples - Optional daily activity quantity samples
411
- * @param args.dailySummaries - Optional daily activity ring summaries
412
- * @param args.nutritionSamples - Optional dietary quantity samples
413
- * @param args.menstruationSamples - Optional menstrual flow category samples
414
- * @param args.characteristics - Optional user characteristics
415
- *
416
- * @example
417
- * ```ts
418
- * const result = await soma.healthkit.syncAll(ctx, {
419
- * userId: "user_123",
420
- * workouts: hkWorkouts,
421
- * sleepSessions: [nightSamples],
422
- * bodySamples: hkBodySamples,
423
- * dailySummaries: hkSummaries,
424
- * characteristics: hkCharacteristics,
425
- * });
426
- * ```
427
- */
428
- async syncAll(
429
- ctx: MutationCtx,
430
- args: {
431
- userId: string;
432
- workouts?: HKWorkout[];
433
- sleepSessions?: HKCategorySample[][];
434
- bodySamples?: HKQuantitySample[];
435
- bodyTimeRange?: { start_time: string; end_time: string };
436
- dailySamples?: HKQuantitySample[];
437
- dailyTimeRange?: { start_time: string; end_time: string };
438
- dailySummaries?: HKActivitySummary[];
439
- nutritionSamples?: HKQuantitySample[];
440
- nutritionTimeRange?: { start_time: string; end_time: string };
441
- menstruationSamples?: HKCategorySample[];
442
- menstruationTimeRange?: { start_time: string; end_time: string };
443
- characteristics?: HKCharacteristics;
444
- },
445
- ): Promise<SyncResult<Record<string, number>>> {
446
- // Resolve connection once, up front
447
- const connectionId = await this.resolveConnection(ctx, args.userId);
448
- const allErrors: SomaError[] = [];
449
- const synced: Record<string, number> = {};
450
-
451
- const run = async <T extends Record<string, number>>(
452
- fn: () => Promise<SyncResult<T>>,
453
- fallbackType: SomaError["type"],
454
- ) => {
455
- try {
456
- const result = await fn();
457
- Object.assign(synced, result.data.synced);
458
- allErrors.push(...result.errors);
459
- } catch (err) {
460
- allErrors.push({
461
- type: fallbackType,
462
- id: "sync",
463
- message: err instanceof Error ? err.message : String(err),
464
- });
465
- }
466
- };
467
-
468
- if (args.workouts) {
469
- await run(
470
- () => this.syncActivitiesInternal(ctx, connectionId, args.userId, args.workouts!),
471
- "activity",
472
- );
473
- }
474
- if (args.sleepSessions) {
475
- await run(
476
- () => this.syncSleepInternal(ctx, connectionId, args.userId, args.sleepSessions!),
477
- "sleep",
478
- );
479
- }
480
- if (args.bodySamples) {
481
- await run(
482
- () => this.syncBodyInternal(ctx, connectionId, args.userId, args.bodySamples!, args.bodyTimeRange),
483
- "body",
484
- );
485
- }
486
- if (args.dailySamples) {
487
- await run(
488
- () => this.syncDailyInternal(ctx, connectionId, args.userId, args.dailySamples!, args.dailyTimeRange),
489
- "daily",
490
- );
491
- }
492
- if (args.dailySummaries) {
493
- await run(
494
- () => this.syncDailyFromSummaryInternal(ctx, connectionId, args.userId, args.dailySummaries!),
495
- "daily",
496
- );
497
- }
498
- if (args.nutritionSamples) {
499
- await run(
500
- () => this.syncNutritionInternal(ctx, connectionId, args.userId, args.nutritionSamples!, args.nutritionTimeRange),
501
- "nutrition" as SomaError["type"],
502
- );
503
- }
504
- if (args.menstruationSamples) {
505
- await run(
506
- () => this.syncMenstruationInternal(ctx, connectionId, args.userId, args.menstruationSamples!, args.menstruationTimeRange),
507
- "menstruation",
508
- );
509
- }
510
- if (args.characteristics) {
511
- await run(
512
- () => this.syncAthleteInternal(ctx, connectionId, args.userId, args.characteristics!),
513
- "athlete",
514
- );
515
- }
516
-
517
- // Update lastDataUpdate once at the end
518
- await this.updateLastDataUpdate(ctx, connectionId);
519
-
520
- return { data: { synced }, errors: allErrors };
521
- }
522
-
523
- // ─── Private Helpers ───────────��─────────────────────────────────────────
524
-
525
- /**
526
- * Resolve or create the APPLE connection for a user.
527
- *
528
- * HealthKit permissions are managed on-device by the React Native library,
529
- * so Soma auto-ensures the connection record exists. If the connection was
530
- * previously disconnected it is re-activated automatically.
531
- */
532
- private async resolveConnection(
533
- ctx: MutationCtx,
534
- userId: string,
535
- ): Promise<string> {
536
- // `connect` is idempotent: creates if missing, re-activates if inactive.
537
- return (await ctx.runMutation(this.component.public.connect, {
538
- userId,
539
- provider: PROVIDER,
540
- })) as string;
541
- }
542
-
543
- private async updateLastDataUpdate(
544
- ctx: MutationCtx,
545
- connectionId: string,
546
- ): Promise<void> {
547
- await ctx.runMutation(this.component.public.updateConnection, {
548
- connectionId,
549
- lastDataUpdate: new Date().toISOString(),
550
- });
551
- }
552
-
553
- // ─── Internal sync methods (skip connection resolution + lastDataUpdate) ─
554
-
555
- private async syncActivitiesInternal(
556
- ctx: MutationCtx,
557
- connectionId: string,
558
- userId: string,
559
- workouts: HKWorkout[],
560
- ): Promise<SyncResult<{ activities: number }>> {
561
- const errors: SomaError[] = [];
562
- let count = 0;
563
-
564
- for (const workout of workouts) {
565
- try {
566
- const data = transformWorkout(workout);
567
- await ctx.runMutation(this.component.public.ingestActivity, {
568
- connectionId,
569
- userId,
570
- ...data,
571
- });
572
- count++;
573
- } catch (err) {
574
- errors.push({
575
- type: "activity",
576
- id: workout.uuid,
577
- message: err instanceof Error ? err.message : String(err),
578
- });
579
- }
580
- }
581
-
582
- return { data: { synced: { activities: count } }, errors };
583
- }
584
-
585
- private async syncSleepInternal(
586
- ctx: MutationCtx,
587
- connectionId: string,
588
- userId: string,
589
- sessions: HKCategorySample[][],
590
- ): Promise<SyncResult<{ sleep: number }>> {
591
- const errors: SomaError[] = [];
592
- let count = 0;
593
-
594
- for (const session of sessions) {
595
- const sessionId = session[0]?.uuid ?? "unknown";
596
- try {
597
- const data = transformSleep(session);
598
- await ctx.runMutation(this.component.public.ingestSleep, {
599
- connectionId,
600
- userId,
601
- ...data,
602
- });
603
- count++;
604
- } catch (err) {
605
- errors.push({
606
- type: "sleep",
607
- id: sessionId,
608
- message: err instanceof Error ? err.message : String(err),
609
- });
610
- }
611
- }
612
-
613
- return { data: { synced: { sleep: count } }, errors };
614
- }
615
-
616
- private async syncBodyInternal(
617
- ctx: MutationCtx,
618
- connectionId: string,
619
- userId: string,
620
- samples: HKQuantitySample[],
621
- timeRange?: { start_time: string; end_time: string },
622
- ): Promise<SyncResult<{ body: number }>> {
623
- const errors: SomaError[] = [];
624
- let count = 0;
625
-
626
- try {
627
- const data = transformBody(samples, timeRange);
628
- await ctx.runMutation(this.component.public.ingestBody, {
629
- connectionId,
630
- userId,
631
- ...data,
632
- });
633
- count++;
634
- } catch (err) {
635
- errors.push({
636
- type: "body",
637
- id: "transform",
638
- message: err instanceof Error ? err.message : String(err),
639
- });
640
- }
641
-
642
- return { data: { synced: { body: count } }, errors };
643
- }
644
-
645
- private async syncDailyInternal(
646
- ctx: MutationCtx,
647
- connectionId: string,
648
- userId: string,
649
- samples: HKQuantitySample[],
650
- timeRange?: { start_time: string; end_time: string },
651
- ): Promise<SyncResult<{ daily: number }>> {
652
- const errors: SomaError[] = [];
653
- let count = 0;
654
-
655
- try {
656
- const data = transformDaily(samples, timeRange);
657
- await ctx.runMutation(this.component.public.ingestDaily, {
658
- connectionId,
659
- userId,
660
- ...data,
661
- });
662
- count++;
663
- } catch (err) {
664
- errors.push({
665
- type: "daily",
666
- id: "transform",
667
- message: err instanceof Error ? err.message : String(err),
668
- });
669
- }
670
-
671
- return { data: { synced: { daily: count } }, errors };
672
- }
673
-
674
- private async syncDailyFromSummaryInternal(
675
- ctx: MutationCtx,
676
- connectionId: string,
677
- userId: string,
678
- summaries: HKActivitySummary[],
679
- ): Promise<SyncResult<{ daily: number }>> {
680
- const errors: SomaError[] = [];
681
- let count = 0;
682
-
683
- for (const summary of summaries) {
684
- const { year, month, day } = summary.dateComponents;
685
- const summaryId = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
686
- try {
687
- const data = transformDailyFromSummary(summary);
688
- await ctx.runMutation(this.component.public.ingestDaily, {
689
- connectionId,
690
- userId,
691
- ...data,
692
- });
693
- count++;
694
- } catch (err) {
695
- errors.push({
696
- type: "daily",
697
- id: summaryId,
698
- message: err instanceof Error ? err.message : String(err),
699
- });
700
- }
701
- }
702
-
703
- return { data: { synced: { daily: count } }, errors };
704
- }
705
-
706
- private async syncNutritionInternal(
707
- ctx: MutationCtx,
708
- connectionId: string,
709
- userId: string,
710
- samples: HKQuantitySample[],
711
- timeRange?: { start_time: string; end_time: string },
712
- ): Promise<SyncResult<{ nutrition: number }>> {
713
- const errors: SomaError[] = [];
714
- let count = 0;
715
-
716
- try {
717
- const data = transformNutrition(samples, timeRange);
718
- await ctx.runMutation(this.component.public.ingestNutrition, {
719
- connectionId,
720
- userId,
721
- ...data,
722
- });
723
- count++;
724
- } catch (err) {
725
- errors.push({
726
- type: "nutrition" as SomaError["type"],
727
- id: "transform",
728
- message: err instanceof Error ? err.message : String(err),
729
- });
730
- }
731
-
732
- return { data: { synced: { nutrition: count } }, errors };
733
- }
734
-
735
- private async syncMenstruationInternal(
736
- ctx: MutationCtx,
737
- connectionId: string,
738
- userId: string,
739
- samples: HKCategorySample[],
740
- timeRange?: { start_time: string; end_time: string },
741
- ): Promise<SyncResult<{ menstruation: number }>> {
742
- const errors: SomaError[] = [];
743
- let count = 0;
744
-
745
- try {
746
- const data = transformMenstruation(samples, timeRange);
747
- await ctx.runMutation(this.component.public.ingestMenstruation, {
748
- connectionId,
749
- userId,
750
- ...data,
751
- });
752
- count++;
753
- } catch (err) {
754
- errors.push({
755
- type: "menstruation",
756
- id: "transform",
757
- message: err instanceof Error ? err.message : String(err),
758
- });
759
- }
760
-
761
- return { data: { synced: { menstruation: count } }, errors };
762
- }
763
-
764
- private async syncAthleteInternal(
765
- ctx: MutationCtx,
766
- connectionId: string,
767
- userId: string,
768
- characteristics: HKCharacteristics,
769
- ): Promise<SyncResult<{ athletes: number }>> {
770
- const errors: SomaError[] = [];
771
- let count = 0;
772
-
773
- try {
774
- const data = transformAthlete(characteristics);
775
- await ctx.runMutation(this.component.public.ingestAthlete, {
776
- connectionId,
777
- userId,
778
- ...data,
779
- });
780
- count++;
781
- } catch (err) {
782
- errors.push({
783
- type: "athlete",
784
- id: "transform",
785
- message: err instanceof Error ? err.message : String(err),
786
- });
787
- }
788
-
789
- return { data: { synced: { athletes: count } }, errors };
790
- }
791
- }
1
+ import type { SomaComponent } from "./index.js";
2
+ import type { MutationCtx, SomaError, SomaResult } from "./types.js";
3
+ import type {
4
+ HKWorkout,
5
+ HKCategorySample,
6
+ HKQuantitySample,
7
+ HKActivitySummary,
8
+ HKCharacteristics,
9
+ } from "../component/healthkit/types.js";
10
+ import { transformWorkout } from "../component/healthkit/transform/activity.js";
11
+ import { transformSleep } from "../component/healthkit/transform/sleep.js";
12
+ import { transformBody } from "../component/healthkit/transform/body.js";
13
+ import {
14
+ transformDaily,
15
+ transformDailyFromSummary,
16
+ } from "../component/healthkit/transform/daily.js";
17
+ import { transformNutrition } from "../component/healthkit/transform/nutrition.js";
18
+ import { transformMenstruation } from "../component/healthkit/transform/menstruation.js";
19
+ import { transformAthlete } from "../component/healthkit/transform/athlete.js";
20
+
21
+ const PROVIDER = "APPLE";
22
+
23
+ /**
24
+ * Client class for Apple HealthKit integration with Soma.
25
+ *
26
+ * Unlike {@link import("./strava.js").SomaStrava | SomaStrava} and
27
+ * {@link import("./garmin.js").SomaGarmin | SomaGarmin}, HealthKit is an
28
+ * on-device provider — data is queried locally on iOS, not fetched from a
29
+ * cloud API. This class wraps the transform + ingest pipeline so the host
30
+ * app gets the same ergonomic interface as cloud providers.
31
+ *
32
+ * Because HealthKit permissions are managed in iOS Settings, the server
33
+ * cannot independently verify whether the user has granted or revoked
34
+ * access. The `connect()` / `disconnect()` methods therefore represent
35
+ * assertions by the host app about the state it has observed on-device:
36
+ *
37
+ * - Call {@link SomaHealthKit.connect | connect} once the React Native
38
+ * HealthKit library confirms permissions were granted.
39
+ * - Call {@link SomaHealthKit.disconnect | disconnect} when the user
40
+ * turns HealthKit off in your app's settings, or when you otherwise
41
+ * detect that syncs are no longer returning data.
42
+ *
43
+ * Sync methods refuse to run unless an active connection exists.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * // Once, after the RN library confirms permissions:
48
+ * await soma.healthkit.connect(ctx, { userId: "user_123" });
49
+ *
50
+ * // Sync workouts received from the React Native client:
51
+ * const result = await soma.healthkit.syncActivities(ctx, {
52
+ * userId: "user_123",
53
+ * workouts: hkWorkouts,
54
+ * });
55
+ * // result: { data: { activities: 5 }, errors: [] }
56
+ *
57
+ * // Or sync everything at once:
58
+ * await soma.healthkit.syncAll(ctx, {
59
+ * userId: "user_123",
60
+ * workouts: hkWorkouts,
61
+ * sleepSessions: [nightSamples],
62
+ * bodySamples: hkBodySamples,
63
+ * });
64
+ *
65
+ * // When the user disables HealthKit in your app:
66
+ * await soma.healthkit.disconnect(ctx, { userId: "user_123" });
67
+ * ```
68
+ */
69
+ export class SomaHealthKit {
70
+ constructor(private component: SomaComponent) { }
71
+
72
+ // ─── Connect / Disconnect ──────────────────────────────────────────────
73
+
74
+ /**
75
+ * Assert that HealthKit is connected for this user.
76
+ *
77
+ * Call this once after the React Native HealthKit library confirms
78
+ * permissions were granted. Creates the APPLE connection if missing,
79
+ * or re-activates it if previously disconnected. Idempotent.
80
+ *
81
+ * Because iOS permission grants happen on-device, the server cannot
82
+ * verify them independently — this method records the host app's
83
+ * assertion that the user has authorized HealthKit access.
84
+ *
85
+ * @param ctx - Mutation context from the host app
86
+ * @param args.userId - The host app's user identifier
87
+ * @returns The connection document ID
88
+ */
89
+ async connect(
90
+ ctx: MutationCtx,
91
+ args: { userId: string },
92
+ ): Promise<string> {
93
+ return (await ctx.runMutation(this.component.public.connect, {
94
+ userId: args.userId,
95
+ provider: PROVIDER,
96
+ })) as string;
97
+ }
98
+
99
+ /**
100
+ * Mark the HealthKit connection inactive for this user.
101
+ *
102
+ * Call this when the user disables HealthKit in your app's settings, or
103
+ * when you detect that sync calls are no longer returning data (suggesting
104
+ * the user revoked permissions in iOS Settings). Subsequent sync methods
105
+ * will throw until {@link SomaHealthKit.connect | connect} is called again.
106
+ *
107
+ * Does not delete the connection row or any previously synced data —
108
+ * re-connecting later preserves history.
109
+ *
110
+ * @param ctx - Mutation context from the host app
111
+ * @param args.userId - The host app's user identifier
112
+ */
113
+ async disconnect(
114
+ ctx: MutationCtx,
115
+ args: { userId: string },
116
+ ): Promise<null> {
117
+ return await ctx.runMutation(this.component.public.disconnect, {
118
+ userId: args.userId,
119
+ provider: PROVIDER,
120
+ });
121
+ }
122
+
123
+ // ─── Per-Type Sync Methods ─────────────────────────────────────────────
124
+
125
+ /**
126
+ * Sync workout activities from HealthKit.
127
+ *
128
+ * Transforms each `HKWorkout` into the Soma Activity schema and ingests it
129
+ * with automatic deduplication by `workout.uuid`.
130
+ *
131
+ * @param ctx - Mutation (or action) context from the host app
132
+ * @param args.userId - The host app's user identifier
133
+ * @param args.workouts - Array of HKWorkout objects from HealthKit
134
+ */
135
+ async syncActivities(
136
+ ctx: MutationCtx,
137
+ args: { userId: string; workouts: HKWorkout[] },
138
+ ): Promise<SomaResult<{ activities: number }>> {
139
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
140
+ const errors: SomaError[] = [];
141
+ let count = 0;
142
+
143
+ for (const workout of args.workouts) {
144
+ try {
145
+ const data = transformWorkout(workout);
146
+ await ctx.runMutation(this.component.public.ingestActivity, {
147
+ connectionId,
148
+ userId: args.userId,
149
+ ...data,
150
+ });
151
+ count++;
152
+ } catch (err) {
153
+ errors.push({
154
+ type: "activity",
155
+ id: workout.uuid,
156
+ message: err instanceof Error ? err.message : String(err),
157
+ });
158
+ }
159
+ }
160
+
161
+ await this.updateLastDataUpdate(ctx, connectionId);
162
+ return { data: { activities: count }, errors };
163
+ }
164
+
165
+ /**
166
+ * Sync sleep sessions from HealthKit.
167
+ *
168
+ * Each inner array represents one sleep session (all `HKCategorySample`
169
+ * stage records for a single night). Each session is aggregated into a
170
+ * single Soma Sleep document.
171
+ *
172
+ * @param ctx - Mutation (or action) context from the host app
173
+ * @param args.userId - The host app's user identifier
174
+ * @param args.sessions - Array of sessions, each an array of sleep-stage samples
175
+ */
176
+ async syncSleep(
177
+ ctx: MutationCtx,
178
+ args: { userId: string; sessions: HKCategorySample[][] },
179
+ ): Promise<SomaResult<{ sleep: number }>> {
180
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
181
+ const errors: SomaError[] = [];
182
+ let count = 0;
183
+
184
+ for (const session of args.sessions) {
185
+ const sessionId = session[0]?.uuid ?? "unknown";
186
+ try {
187
+ const data = transformSleep(session);
188
+ await ctx.runMutation(this.component.public.ingestSleep, {
189
+ connectionId,
190
+ userId: args.userId,
191
+ ...data,
192
+ });
193
+ count++;
194
+ } catch (err) {
195
+ errors.push({
196
+ type: "sleep",
197
+ id: sessionId,
198
+ message: err instanceof Error ? err.message : String(err),
199
+ });
200
+ }
201
+ }
202
+
203
+ await this.updateLastDataUpdate(ctx, connectionId);
204
+ return { data: { sleep: count }, errors };
205
+ }
206
+
207
+ /**
208
+ * Sync body metrics from HealthKit.
209
+ *
210
+ * Accepts a mixed array of body-related quantity samples (heart rate, HRV,
211
+ * blood pressure, SpO2, weight, etc.) for a single time window and produces
212
+ * one Soma Body document.
213
+ *
214
+ * @param ctx - Mutation (or action) context from the host app
215
+ * @param args.userId - The host app's user identifier
216
+ * @param args.samples - Array of HKQuantitySample for the desired time range
217
+ * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
218
+ */
219
+ async syncBody(
220
+ ctx: MutationCtx,
221
+ args: {
222
+ userId: string;
223
+ samples: HKQuantitySample[];
224
+ timeRange?: { start_time: string; end_time: string };
225
+ },
226
+ ): Promise<SomaResult<{ body: number }>> {
227
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
228
+ const errors: SomaError[] = [];
229
+ let count = 0;
230
+
231
+ try {
232
+ const data = transformBody(args.samples, args.timeRange);
233
+ await ctx.runMutation(this.component.public.ingestBody, {
234
+ connectionId,
235
+ userId: args.userId,
236
+ ...data,
237
+ });
238
+ count++;
239
+ } catch (err) {
240
+ errors.push({
241
+ type: "body",
242
+ id: "transform",
243
+ message: err instanceof Error ? err.message : String(err),
244
+ });
245
+ }
246
+
247
+ await this.updateLastDataUpdate(ctx, connectionId);
248
+ return { data: { body: count }, errors };
249
+ }
250
+
251
+ /**
252
+ * Sync daily activity data from HealthKit quantity samples.
253
+ *
254
+ * Accepts samples for a single day (steps, distance, energy, exercise time,
255
+ * heart rate, etc.) and produces one Soma Daily document.
256
+ *
257
+ * @param ctx - Mutation (or action) context from the host app
258
+ * @param args.userId - The host app's user identifier
259
+ * @param args.samples - Array of HKQuantitySample for the desired day
260
+ * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
261
+ */
262
+ async syncDaily(
263
+ ctx: MutationCtx,
264
+ args: {
265
+ userId: string;
266
+ samples: HKQuantitySample[];
267
+ timeRange?: { start_time: string; end_time: string };
268
+ },
269
+ ): Promise<SomaResult<{ daily: number }>> {
270
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
271
+ const errors: SomaError[] = [];
272
+ let count = 0;
273
+
274
+ try {
275
+ const data = transformDaily(args.samples, args.timeRange);
276
+ await ctx.runMutation(this.component.public.ingestDaily, {
277
+ connectionId,
278
+ userId: args.userId,
279
+ ...data,
280
+ });
281
+ count++;
282
+ } catch (err) {
283
+ errors.push({
284
+ type: "daily",
285
+ id: "transform",
286
+ message: err instanceof Error ? err.message : String(err),
287
+ });
288
+ }
289
+
290
+ await this.updateLastDataUpdate(ctx, connectionId);
291
+ return { data: { daily: count }, errors };
292
+ }
293
+
294
+ /**
295
+ * Sync daily activity data from HealthKit activity ring summaries.
296
+ *
297
+ * Each `HKActivitySummary` represents one day's activity rings (Move,
298
+ * Exercise, Stand). Each summary produces one Soma Daily document.
299
+ *
300
+ * @param ctx - Mutation (or action) context from the host app
301
+ * @param args.userId - The host app's user identifier
302
+ * @param args.summaries - Array of HKActivitySummary from HealthKit
303
+ */
304
+ async syncDailyFromSummary(
305
+ ctx: MutationCtx,
306
+ args: { userId: string; summaries: HKActivitySummary[] },
307
+ ): Promise<SomaResult<{ daily: number }>> {
308
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
309
+ const errors: SomaError[] = [];
310
+ let count = 0;
311
+
312
+ for (const summary of args.summaries) {
313
+ const { year, month, day } = summary.dateComponents;
314
+ const summaryId = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
315
+ try {
316
+ const data = transformDailyFromSummary(summary);
317
+ await ctx.runMutation(this.component.public.ingestDaily, {
318
+ connectionId,
319
+ userId: args.userId,
320
+ ...data,
321
+ });
322
+ count++;
323
+ } catch (err) {
324
+ errors.push({
325
+ type: "daily",
326
+ id: summaryId,
327
+ message: err instanceof Error ? err.message : String(err),
328
+ });
329
+ }
330
+ }
331
+
332
+ await this.updateLastDataUpdate(ctx, connectionId);
333
+ return { data: { daily: count }, errors };
334
+ }
335
+
336
+ /**
337
+ * Sync nutrition data from HealthKit.
338
+ *
339
+ * Accepts dietary quantity samples for a single time window and produces
340
+ * one Soma Nutrition document with macros and micros.
341
+ *
342
+ * @param ctx - Mutation (or action) context from the host app
343
+ * @param args.userId - The host app's user identifier
344
+ * @param args.samples - Array of HKQuantitySample with dietary type identifiers
345
+ * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
346
+ */
347
+ async syncNutrition(
348
+ ctx: MutationCtx,
349
+ args: {
350
+ userId: string;
351
+ samples: HKQuantitySample[];
352
+ timeRange?: { start_time: string; end_time: string };
353
+ },
354
+ ): Promise<SomaResult<{ nutrition: number }>> {
355
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
356
+ const errors: SomaError[] = [];
357
+ let count = 0;
358
+
359
+ try {
360
+ const data = transformNutrition(args.samples, args.timeRange);
361
+ await ctx.runMutation(this.component.public.ingestNutrition, {
362
+ connectionId,
363
+ userId: args.userId,
364
+ ...data,
365
+ });
366
+ count++;
367
+ } catch (err) {
368
+ errors.push({
369
+ type: "nutrition" as SomaError["type"],
370
+ id: "transform",
371
+ message: err instanceof Error ? err.message : String(err),
372
+ });
373
+ }
374
+
375
+ await this.updateLastDataUpdate(ctx, connectionId);
376
+ return { data: { nutrition: count }, errors };
377
+ }
378
+
379
+ /**
380
+ * Sync menstruation data from HealthKit.
381
+ *
382
+ * Accepts menstrual flow category samples for a single time window and
383
+ * produces one Soma Menstruation document.
384
+ *
385
+ * @param ctx - Mutation (or action) context from the host app
386
+ * @param args.userId - The host app's user identifier
387
+ * @param args.samples - Array of HKCategorySample with menstrual flow values
388
+ * @param args.timeRange - Optional explicit time range; auto-detected from samples if omitted
389
+ */
390
+ async syncMenstruation(
391
+ ctx: MutationCtx,
392
+ args: {
393
+ userId: string;
394
+ samples: HKCategorySample[];
395
+ timeRange?: { start_time: string; end_time: string };
396
+ },
397
+ ): Promise<SomaResult<{ menstruation: number }>> {
398
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
399
+ const errors: SomaError[] = [];
400
+ let count = 0;
401
+
402
+ try {
403
+ const data = transformMenstruation(args.samples, args.timeRange);
404
+ await ctx.runMutation(this.component.public.ingestMenstruation, {
405
+ connectionId,
406
+ userId: args.userId,
407
+ ...data,
408
+ });
409
+ count++;
410
+ } catch (err) {
411
+ errors.push({
412
+ type: "menstruation",
413
+ id: "transform",
414
+ message: err instanceof Error ? err.message : String(err),
415
+ });
416
+ }
417
+
418
+ await this.updateLastDataUpdate(ctx, connectionId);
419
+ return { data: { menstruation: count }, errors };
420
+ }
421
+
422
+ /**
423
+ * Sync athlete profile from HealthKit.
424
+ *
425
+ * HealthKit exposes limited profile data (biological sex, date of birth).
426
+ * Produces one Soma Athlete document per connection.
427
+ *
428
+ * @param ctx - Mutation (or action) context from the host app
429
+ * @param args.userId - The host app's user identifier
430
+ * @param args.characteristics - The HKCharacteristics from HealthKit
431
+ */
432
+ async syncAthlete(
433
+ ctx: MutationCtx,
434
+ args: { userId: string; characteristics: HKCharacteristics },
435
+ ): Promise<SomaResult<{ athletes: number }>> {
436
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
437
+ const errors: SomaError[] = [];
438
+ let count = 0;
439
+
440
+ try {
441
+ const data = transformAthlete(args.characteristics);
442
+ await ctx.runMutation(this.component.public.ingestAthlete, {
443
+ connectionId,
444
+ userId: args.userId,
445
+ ...data,
446
+ });
447
+ count++;
448
+ } catch (err) {
449
+ errors.push({
450
+ type: "athlete",
451
+ id: "transform",
452
+ message: err instanceof Error ? err.message : String(err),
453
+ });
454
+ }
455
+
456
+ await this.updateLastDataUpdate(ctx, connectionId);
457
+ return { data: { athletes: count }, errors };
458
+ }
459
+
460
+ // ─── Orchestrator ────────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Sync all provided HealthKit data types in a single call.
464
+ *
465
+ * Only data types with values provided are synced. Errors from individual
466
+ * data types are collected — one failing type does not block others.
467
+ *
468
+ * @param ctx - Mutation (or action) context from the host app
469
+ * @param args.userId - The host app's user identifier
470
+ * @param args.workouts - Optional array of HKWorkout objects
471
+ * @param args.sleepSessions - Optional array of sleep sessions (each an array of stage samples)
472
+ * @param args.bodySamples - Optional body-related quantity samples
473
+ * @param args.dailySamples - Optional daily activity quantity samples
474
+ * @param args.dailySummaries - Optional daily activity ring summaries
475
+ * @param args.nutritionSamples - Optional dietary quantity samples
476
+ * @param args.menstruationSamples - Optional menstrual flow category samples
477
+ * @param args.characteristics - Optional user characteristics
478
+ *
479
+ * @example
480
+ * ```ts
481
+ * const result = await soma.healthkit.syncAll(ctx, {
482
+ * userId: "user_123",
483
+ * workouts: hkWorkouts,
484
+ * sleepSessions: [nightSamples],
485
+ * bodySamples: hkBodySamples,
486
+ * dailySummaries: hkSummaries,
487
+ * characteristics: hkCharacteristics,
488
+ * });
489
+ * ```
490
+ */
491
+ async syncAll(
492
+ ctx: MutationCtx,
493
+ args: {
494
+ userId: string;
495
+ workouts?: HKWorkout[];
496
+ sleepSessions?: HKCategorySample[][];
497
+ bodySamples?: HKQuantitySample[];
498
+ bodyTimeRange?: { start_time: string; end_time: string };
499
+ dailySamples?: HKQuantitySample[];
500
+ dailyTimeRange?: { start_time: string; end_time: string };
501
+ dailySummaries?: HKActivitySummary[];
502
+ nutritionSamples?: HKQuantitySample[];
503
+ nutritionTimeRange?: { start_time: string; end_time: string };
504
+ menstruationSamples?: HKCategorySample[];
505
+ menstruationTimeRange?: { start_time: string; end_time: string };
506
+ characteristics?: HKCharacteristics;
507
+ },
508
+ ): Promise<SomaResult<Record<string, number>>> {
509
+ const connectionId = await this.requireActiveConnection(ctx, args.userId);
510
+ const allErrors: SomaError[] = [];
511
+ const counts: Record<string, number> = {};
512
+
513
+ const run = async <T extends Record<string, number>>(
514
+ fn: () => Promise<SomaResult<T>>,
515
+ fallbackType: SomaError["type"],
516
+ ) => {
517
+ try {
518
+ const result = await fn();
519
+ Object.assign(counts, result.data);
520
+ allErrors.push(...result.errors);
521
+ } catch (err) {
522
+ allErrors.push({
523
+ type: fallbackType,
524
+ id: "sync",
525
+ message: err instanceof Error ? err.message : String(err),
526
+ });
527
+ }
528
+ };
529
+
530
+ if (args.workouts) {
531
+ await run(
532
+ () => this.syncActivitiesInternal(ctx, connectionId, args.userId, args.workouts!),
533
+ "activity",
534
+ );
535
+ }
536
+ if (args.sleepSessions) {
537
+ await run(
538
+ () => this.syncSleepInternal(ctx, connectionId, args.userId, args.sleepSessions!),
539
+ "sleep",
540
+ );
541
+ }
542
+ if (args.bodySamples) {
543
+ await run(
544
+ () => this.syncBodyInternal(ctx, connectionId, args.userId, args.bodySamples!, args.bodyTimeRange),
545
+ "body",
546
+ );
547
+ }
548
+ if (args.dailySamples) {
549
+ await run(
550
+ () => this.syncDailyInternal(ctx, connectionId, args.userId, args.dailySamples!, args.dailyTimeRange),
551
+ "daily",
552
+ );
553
+ }
554
+ if (args.dailySummaries) {
555
+ await run(
556
+ () => this.syncDailyFromSummaryInternal(ctx, connectionId, args.userId, args.dailySummaries!),
557
+ "daily",
558
+ );
559
+ }
560
+ if (args.nutritionSamples) {
561
+ await run(
562
+ () => this.syncNutritionInternal(ctx, connectionId, args.userId, args.nutritionSamples!, args.nutritionTimeRange),
563
+ "nutrition" as SomaError["type"],
564
+ );
565
+ }
566
+ if (args.menstruationSamples) {
567
+ await run(
568
+ () => this.syncMenstruationInternal(ctx, connectionId, args.userId, args.menstruationSamples!, args.menstruationTimeRange),
569
+ "menstruation",
570
+ );
571
+ }
572
+ if (args.characteristics) {
573
+ await run(
574
+ () => this.syncAthleteInternal(ctx, connectionId, args.userId, args.characteristics!),
575
+ "athlete",
576
+ );
577
+ }
578
+
579
+ // Update lastDataUpdate once at the end
580
+ await this.updateLastDataUpdate(ctx, connectionId);
581
+
582
+ return { data: counts, errors: allErrors };
583
+ }
584
+
585
+ // ─── Private Helpers ───────────��─────────────────────────────────────────
586
+
587
+ /**
588
+ * Load the active APPLE connection for a user, or throw if missing/inactive.
589
+ *
590
+ * HealthKit permissions are managed on-device, so the connection state is
591
+ * an assertion made by the host app via {@link SomaHealthKit.connect} and
592
+ * {@link SomaHealthKit.disconnect}. Sync methods refuse to run without an
593
+ * active connection — the host app must explicitly reconnect to resume.
594
+ */
595
+ private async requireActiveConnection(
596
+ ctx: MutationCtx,
597
+ userId: string,
598
+ ): Promise<string> {
599
+ const connection = await ctx.runQuery(
600
+ this.component.public.getConnectionByProvider,
601
+ { userId, provider: PROVIDER },
602
+ );
603
+ if (!connection) {
604
+ throw new Error(
605
+ `No HealthKit connection for user "${userId}". Call soma.healthkit.connect(ctx, { userId }) after the React Native HealthKit library confirms permissions.`,
606
+ );
607
+ }
608
+ if (connection.active === false) {
609
+ throw new Error(
610
+ `HealthKit connection for user "${userId}" is disconnected. Call soma.healthkit.connect(ctx, { userId }) to re-activate.`,
611
+ );
612
+ }
613
+ return connection._id;
614
+ }
615
+
616
+ private async updateLastDataUpdate(
617
+ ctx: MutationCtx,
618
+ connectionId: string,
619
+ ): Promise<void> {
620
+ await ctx.runMutation(this.component.public.updateConnection, {
621
+ connectionId,
622
+ lastDataUpdate: new Date().toISOString(),
623
+ });
624
+ }
625
+
626
+ // ─── Internal sync methods (skip connection resolution + lastDataUpdate) ─
627
+
628
+ private async syncActivitiesInternal(
629
+ ctx: MutationCtx,
630
+ connectionId: string,
631
+ userId: string,
632
+ workouts: HKWorkout[],
633
+ ): Promise<SomaResult<{ activities: number }>> {
634
+ const errors: SomaError[] = [];
635
+ let count = 0;
636
+
637
+ for (const workout of workouts) {
638
+ try {
639
+ const data = transformWorkout(workout);
640
+ await ctx.runMutation(this.component.public.ingestActivity, {
641
+ connectionId,
642
+ userId,
643
+ ...data,
644
+ });
645
+ count++;
646
+ } catch (err) {
647
+ errors.push({
648
+ type: "activity",
649
+ id: workout.uuid,
650
+ message: err instanceof Error ? err.message : String(err),
651
+ });
652
+ }
653
+ }
654
+
655
+ return { data: { activities: count }, errors };
656
+ }
657
+
658
+ private async syncSleepInternal(
659
+ ctx: MutationCtx,
660
+ connectionId: string,
661
+ userId: string,
662
+ sessions: HKCategorySample[][],
663
+ ): Promise<SomaResult<{ sleep: number }>> {
664
+ const errors: SomaError[] = [];
665
+ let count = 0;
666
+
667
+ for (const session of sessions) {
668
+ const sessionId = session[0]?.uuid ?? "unknown";
669
+ try {
670
+ const data = transformSleep(session);
671
+ await ctx.runMutation(this.component.public.ingestSleep, {
672
+ connectionId,
673
+ userId,
674
+ ...data,
675
+ });
676
+ count++;
677
+ } catch (err) {
678
+ errors.push({
679
+ type: "sleep",
680
+ id: sessionId,
681
+ message: err instanceof Error ? err.message : String(err),
682
+ });
683
+ }
684
+ }
685
+
686
+ return { data: { sleep: count }, errors };
687
+ }
688
+
689
+ private async syncBodyInternal(
690
+ ctx: MutationCtx,
691
+ connectionId: string,
692
+ userId: string,
693
+ samples: HKQuantitySample[],
694
+ timeRange?: { start_time: string; end_time: string },
695
+ ): Promise<SomaResult<{ body: number }>> {
696
+ const errors: SomaError[] = [];
697
+ let count = 0;
698
+
699
+ try {
700
+ const data = transformBody(samples, timeRange);
701
+ await ctx.runMutation(this.component.public.ingestBody, {
702
+ connectionId,
703
+ userId,
704
+ ...data,
705
+ });
706
+ count++;
707
+ } catch (err) {
708
+ errors.push({
709
+ type: "body",
710
+ id: "transform",
711
+ message: err instanceof Error ? err.message : String(err),
712
+ });
713
+ }
714
+
715
+ return { data: { body: count }, errors };
716
+ }
717
+
718
+ private async syncDailyInternal(
719
+ ctx: MutationCtx,
720
+ connectionId: string,
721
+ userId: string,
722
+ samples: HKQuantitySample[],
723
+ timeRange?: { start_time: string; end_time: string },
724
+ ): Promise<SomaResult<{ daily: number }>> {
725
+ const errors: SomaError[] = [];
726
+ let count = 0;
727
+
728
+ try {
729
+ const data = transformDaily(samples, timeRange);
730
+ await ctx.runMutation(this.component.public.ingestDaily, {
731
+ connectionId,
732
+ userId,
733
+ ...data,
734
+ });
735
+ count++;
736
+ } catch (err) {
737
+ errors.push({
738
+ type: "daily",
739
+ id: "transform",
740
+ message: err instanceof Error ? err.message : String(err),
741
+ });
742
+ }
743
+
744
+ return { data: { daily: count }, errors };
745
+ }
746
+
747
+ private async syncDailyFromSummaryInternal(
748
+ ctx: MutationCtx,
749
+ connectionId: string,
750
+ userId: string,
751
+ summaries: HKActivitySummary[],
752
+ ): Promise<SomaResult<{ daily: number }>> {
753
+ const errors: SomaError[] = [];
754
+ let count = 0;
755
+
756
+ for (const summary of summaries) {
757
+ const { year, month, day } = summary.dateComponents;
758
+ const summaryId = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
759
+ try {
760
+ const data = transformDailyFromSummary(summary);
761
+ await ctx.runMutation(this.component.public.ingestDaily, {
762
+ connectionId,
763
+ userId,
764
+ ...data,
765
+ });
766
+ count++;
767
+ } catch (err) {
768
+ errors.push({
769
+ type: "daily",
770
+ id: summaryId,
771
+ message: err instanceof Error ? err.message : String(err),
772
+ });
773
+ }
774
+ }
775
+
776
+ return { data: { daily: count }, errors };
777
+ }
778
+
779
+ private async syncNutritionInternal(
780
+ ctx: MutationCtx,
781
+ connectionId: string,
782
+ userId: string,
783
+ samples: HKQuantitySample[],
784
+ timeRange?: { start_time: string; end_time: string },
785
+ ): Promise<SomaResult<{ nutrition: number }>> {
786
+ const errors: SomaError[] = [];
787
+ let count = 0;
788
+
789
+ try {
790
+ const data = transformNutrition(samples, timeRange);
791
+ await ctx.runMutation(this.component.public.ingestNutrition, {
792
+ connectionId,
793
+ userId,
794
+ ...data,
795
+ });
796
+ count++;
797
+ } catch (err) {
798
+ errors.push({
799
+ type: "nutrition" as SomaError["type"],
800
+ id: "transform",
801
+ message: err instanceof Error ? err.message : String(err),
802
+ });
803
+ }
804
+
805
+ return { data: { nutrition: count }, errors };
806
+ }
807
+
808
+ private async syncMenstruationInternal(
809
+ ctx: MutationCtx,
810
+ connectionId: string,
811
+ userId: string,
812
+ samples: HKCategorySample[],
813
+ timeRange?: { start_time: string; end_time: string },
814
+ ): Promise<SomaResult<{ menstruation: number }>> {
815
+ const errors: SomaError[] = [];
816
+ let count = 0;
817
+
818
+ try {
819
+ const data = transformMenstruation(samples, timeRange);
820
+ await ctx.runMutation(this.component.public.ingestMenstruation, {
821
+ connectionId,
822
+ userId,
823
+ ...data,
824
+ });
825
+ count++;
826
+ } catch (err) {
827
+ errors.push({
828
+ type: "menstruation",
829
+ id: "transform",
830
+ message: err instanceof Error ? err.message : String(err),
831
+ });
832
+ }
833
+
834
+ return { data: { menstruation: count }, errors };
835
+ }
836
+
837
+ private async syncAthleteInternal(
838
+ ctx: MutationCtx,
839
+ connectionId: string,
840
+ userId: string,
841
+ characteristics: HKCharacteristics,
842
+ ): Promise<SomaResult<{ athletes: number }>> {
843
+ const errors: SomaError[] = [];
844
+ let count = 0;
845
+
846
+ try {
847
+ const data = transformAthlete(characteristics);
848
+ await ctx.runMutation(this.component.public.ingestAthlete, {
849
+ connectionId,
850
+ userId,
851
+ ...data,
852
+ });
853
+ count++;
854
+ } catch (err) {
855
+ errors.push({
856
+ type: "athlete",
857
+ id: "transform",
858
+ message: err instanceof Error ? err.message : String(err),
859
+ });
860
+ }
861
+
862
+ return { data: { athletes: count }, errors };
863
+ }
864
+ }