@nativesquare/soma 0.14.0 → 0.16.0

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.
Files changed (164) hide show
  1. package/dist/client/garmin.d.ts +31 -0
  2. package/dist/client/garmin.d.ts.map +1 -1
  3. package/dist/client/garmin.js +34 -0
  4. package/dist/client/garmin.js.map +1 -1
  5. package/dist/client/healthkit.d.ts +267 -0
  6. package/dist/client/healthkit.d.ts.map +1 -0
  7. package/dist/client/healthkit.js +600 -0
  8. package/dist/client/healthkit.js.map +1 -0
  9. package/dist/client/index.d.ts +4 -1
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +4 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/client/types.d.ts +3 -2
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/component/_generated/api.d.ts +26 -0
  16. package/dist/component/_generated/api.d.ts.map +1 -1
  17. package/dist/component/_generated/api.js.map +1 -1
  18. package/dist/component/_generated/component.d.ts +7 -0
  19. package/dist/component/_generated/component.d.ts.map +1 -1
  20. package/dist/component/garmin/private.d.ts +18 -85
  21. package/dist/component/garmin/private.d.ts.map +1 -1
  22. package/dist/component/garmin/private.js +12 -12
  23. package/dist/component/garmin/private.js.map +1 -1
  24. package/dist/component/garmin/public.d.ts +38 -65
  25. package/dist/component/garmin/public.d.ts.map +1 -1
  26. package/dist/component/garmin/public.js +132 -10
  27. package/dist/component/garmin/public.js.map +1 -1
  28. package/dist/component/garmin/utils.d.ts +48 -0
  29. package/dist/component/garmin/utils.d.ts.map +1 -1
  30. package/dist/component/garmin/utils.js +65 -0
  31. package/dist/component/garmin/utils.js.map +1 -1
  32. package/dist/component/garmin/webhooks.d.ts +1 -11
  33. package/dist/component/garmin/webhooks.d.ts.map +1 -1
  34. package/dist/component/garmin/webhooks.js.map +1 -1
  35. package/dist/component/healthkit/index.d.ts +14 -0
  36. package/dist/component/healthkit/index.d.ts.map +1 -0
  37. package/dist/{healthkit → component/healthkit}/index.js +11 -11
  38. package/dist/component/healthkit/index.js.map +1 -0
  39. package/dist/component/healthkit/transform/activity.d.ts +19 -0
  40. package/dist/component/healthkit/transform/activity.d.ts.map +1 -0
  41. package/dist/{healthkit → component/healthkit/transform}/activity.js +1 -1
  42. package/dist/component/healthkit/transform/activity.js.map +1 -0
  43. package/dist/{healthkit → component/healthkit/transform}/athlete.d.ts +3 -9
  44. package/dist/component/healthkit/transform/athlete.d.ts.map +1 -0
  45. package/dist/component/healthkit/transform/athlete.js.map +1 -0
  46. package/dist/component/healthkit/transform/body.d.ts +25 -0
  47. package/dist/component/healthkit/transform/body.d.ts.map +1 -0
  48. package/dist/component/healthkit/transform/body.js.map +1 -0
  49. package/dist/component/healthkit/transform/daily.d.ts +36 -0
  50. package/dist/component/healthkit/transform/daily.d.ts.map +1 -0
  51. package/dist/component/healthkit/transform/daily.js.map +1 -0
  52. package/dist/{healthkit/maps/activity-type.d.ts → component/healthkit/transform/maps/activityType.d.ts} +1 -1
  53. package/dist/component/healthkit/transform/maps/activityType.d.ts.map +1 -0
  54. package/dist/{healthkit/maps/activity-type.js → component/healthkit/transform/maps/activityType.js} +1 -1
  55. package/dist/component/healthkit/transform/maps/activityType.js.map +1 -0
  56. package/dist/{healthkit/maps/menstruation-flow.d.ts → component/healthkit/transform/maps/menstruationFlow.d.ts} +1 -1
  57. package/dist/component/healthkit/transform/maps/menstruationFlow.d.ts.map +1 -0
  58. package/dist/{healthkit/maps/menstruation-flow.js → component/healthkit/transform/maps/menstruationFlow.js} +2 -2
  59. package/dist/component/healthkit/transform/maps/menstruationFlow.js.map +1 -0
  60. package/dist/{healthkit/maps/sleep-level.d.ts → component/healthkit/transform/maps/sleepLevel.d.ts} +1 -1
  61. package/dist/component/healthkit/transform/maps/sleepLevel.d.ts.map +1 -0
  62. package/dist/{healthkit/maps/sleep-level.js → component/healthkit/transform/maps/sleepLevel.js} +2 -2
  63. package/dist/component/healthkit/transform/maps/sleepLevel.js.map +1 -0
  64. package/dist/{healthkit → component/healthkit/transform}/menstruation.d.ts +3 -17
  65. package/dist/component/healthkit/transform/menstruation.d.ts.map +1 -0
  66. package/dist/{healthkit → component/healthkit/transform}/menstruation.js +1 -1
  67. package/dist/component/healthkit/transform/menstruation.js.map +1 -0
  68. package/dist/component/healthkit/transform/nutrition.d.ts +25 -0
  69. package/dist/component/healthkit/transform/nutrition.d.ts.map +1 -0
  70. package/dist/component/healthkit/transform/nutrition.js.map +1 -0
  71. package/dist/component/healthkit/transform/sleep.d.ts +22 -0
  72. package/dist/component/healthkit/transform/sleep.d.ts.map +1 -0
  73. package/dist/{healthkit → component/healthkit/transform}/sleep.js +2 -2
  74. package/dist/component/healthkit/transform/sleep.js.map +1 -0
  75. package/dist/{healthkit → component/healthkit/transform}/utils.d.ts +1 -1
  76. package/dist/component/healthkit/transform/utils.d.ts.map +1 -0
  77. package/dist/component/healthkit/transform/utils.js.map +1 -0
  78. package/dist/component/healthkit/types.d.ts.map +1 -0
  79. package/dist/component/healthkit/types.js.map +1 -0
  80. package/dist/component/public.d.ts +3 -3
  81. package/dist/component/schema.d.ts +4 -4
  82. package/dist/component/strava/public.d.ts +4 -15
  83. package/dist/component/strava/public.d.ts.map +1 -1
  84. package/dist/component/strava/public.js +4 -3
  85. package/dist/component/strava/public.js.map +1 -1
  86. package/dist/component/strava/webhooks.d.ts +3 -10
  87. package/dist/component/strava/webhooks.d.ts.map +1 -1
  88. package/dist/component/strava/webhooks.js.map +1 -1
  89. package/dist/component/validators/daily.d.ts +2 -2
  90. package/dist/component/validators/shared.d.ts +16 -3
  91. package/dist/component/validators/shared.d.ts.map +1 -1
  92. package/dist/component/validators/shared.js +1 -1
  93. package/dist/component/validators/shared.js.map +1 -1
  94. package/dist/validators.d.ts +5 -4
  95. package/dist/validators.d.ts.map +1 -1
  96. package/dist/validators.js.map +1 -1
  97. package/package.json +3 -3
  98. package/src/client/garmin.ts +42 -0
  99. package/src/client/healthkit.ts +791 -0
  100. package/src/client/index.ts +5 -0
  101. package/src/client/types.ts +4 -2
  102. package/src/component/_generated/api.ts +26 -0
  103. package/src/component/_generated/component.ts +13 -0
  104. package/src/component/garmin/private.ts +12 -12
  105. package/src/component/garmin/public.ts +166 -11
  106. package/src/component/garmin/utils.ts +102 -0
  107. package/src/component/garmin/webhooks.ts +1 -7
  108. package/src/{healthkit → component/healthkit}/index.ts +46 -59
  109. package/src/component/healthkit/transform/activity.ts +115 -0
  110. package/src/{healthkit → component/healthkit/transform}/athlete.ts +4 -8
  111. package/src/{healthkit → component/healthkit/transform}/body.ts +3 -7
  112. package/src/{healthkit → component/healthkit/transform}/daily.ts +4 -10
  113. package/src/{healthkit/maps/menstruation-flow.ts → component/healthkit/transform/maps/menstruationFlow.ts} +1 -1
  114. package/src/{healthkit/maps/sleep-level.ts → component/healthkit/transform/maps/sleepLevel.ts} +1 -1
  115. package/src/{healthkit → component/healthkit/transform}/menstruation.ts +4 -8
  116. package/src/{healthkit → component/healthkit/transform}/nutrition.ts +3 -7
  117. package/src/{healthkit → component/healthkit/transform}/sleep.ts +5 -9
  118. package/src/{healthkit → component/healthkit/transform}/utils.ts +1 -1
  119. package/src/component/strava/public.ts +6 -5
  120. package/src/component/strava/webhooks.ts +9 -11
  121. package/src/component/validators/shared.ts +47 -4
  122. package/src/validators.ts +1 -0
  123. package/dist/healthkit/activity.d.ts +0 -75
  124. package/dist/healthkit/activity.d.ts.map +0 -1
  125. package/dist/healthkit/activity.js.map +0 -1
  126. package/dist/healthkit/athlete.d.ts.map +0 -1
  127. package/dist/healthkit/athlete.js.map +0 -1
  128. package/dist/healthkit/body.d.ts +0 -102
  129. package/dist/healthkit/body.d.ts.map +0 -1
  130. package/dist/healthkit/body.js.map +0 -1
  131. package/dist/healthkit/daily.d.ts +0 -119
  132. package/dist/healthkit/daily.d.ts.map +0 -1
  133. package/dist/healthkit/daily.js.map +0 -1
  134. package/dist/healthkit/index.d.ts +0 -21
  135. package/dist/healthkit/index.d.ts.map +0 -1
  136. package/dist/healthkit/index.js.map +0 -1
  137. package/dist/healthkit/maps/activity-type.d.ts.map +0 -1
  138. package/dist/healthkit/maps/activity-type.js.map +0 -1
  139. package/dist/healthkit/maps/menstruation-flow.d.ts.map +0 -1
  140. package/dist/healthkit/maps/menstruation-flow.js.map +0 -1
  141. package/dist/healthkit/maps/sleep-level.d.ts.map +0 -1
  142. package/dist/healthkit/maps/sleep-level.js.map +0 -1
  143. package/dist/healthkit/menstruation.d.ts.map +0 -1
  144. package/dist/healthkit/menstruation.js.map +0 -1
  145. package/dist/healthkit/nutrition.d.ts +0 -77
  146. package/dist/healthkit/nutrition.d.ts.map +0 -1
  147. package/dist/healthkit/nutrition.js.map +0 -1
  148. package/dist/healthkit/sleep.d.ts +0 -60
  149. package/dist/healthkit/sleep.d.ts.map +0 -1
  150. package/dist/healthkit/sleep.js.map +0 -1
  151. package/dist/healthkit/types.d.ts.map +0 -1
  152. package/dist/healthkit/types.js.map +0 -1
  153. package/dist/healthkit/utils.d.ts.map +0 -1
  154. package/dist/healthkit/utils.js.map +0 -1
  155. package/src/healthkit/activity.ts +0 -120
  156. /package/dist/{healthkit → component/healthkit/transform}/athlete.js +0 -0
  157. /package/dist/{healthkit → component/healthkit/transform}/body.js +0 -0
  158. /package/dist/{healthkit → component/healthkit/transform}/daily.js +0 -0
  159. /package/dist/{healthkit → component/healthkit/transform}/nutrition.js +0 -0
  160. /package/dist/{healthkit → component/healthkit/transform}/utils.js +0 -0
  161. /package/dist/{healthkit → component/healthkit}/types.d.ts +0 -0
  162. /package/dist/{healthkit → component/healthkit}/types.js +0 -0
  163. /package/src/{healthkit/maps/activity-type.ts → component/healthkit/transform/maps/activityType.ts} +0 -0
  164. /package/src/{healthkit → component/healthkit}/types.ts +0 -0
@@ -12,6 +12,7 @@ import type {
12
12
  export type {
13
13
  ActionCtx,
14
14
  SomaError,
15
+ SomaErrorType,
15
16
  SomaResult,
16
17
  SomaStravaConfig,
17
18
  SomaGarminConfig,
@@ -40,9 +41,11 @@ export type {
40
41
  } from "./types.js";
41
42
  import type { HttpRouter } from "convex/server";
42
43
  import { SomaGarmin } from "./garmin.js";
44
+ import { SomaHealthKit } from "./healthkit.js";
43
45
  import { SomaStrava } from "./strava.js";
44
46
 
45
47
  export { SomaGarmin } from "./garmin.js";
48
+ export { SomaHealthKit } from "./healthkit.js";
46
49
  export { SomaStrava } from "./strava.js";
47
50
  export { STRAVA_OAUTH_CALLBACK_PATH, STRAVA_WEBHOOK_BASE_PATH } from "./strava.js";
48
51
  export { GARMIN_OAUTH_CALLBACK_PATH, GARMIN_WEBHOOK_BASE_PATH } from "./garmin.js";
@@ -90,6 +93,7 @@ export class Soma {
90
93
  private garminConfig?: SomaGarminConfig;
91
94
 
92
95
  public readonly garmin: SomaGarmin;
96
+ public readonly healthkit: SomaHealthKit;
93
97
  public readonly strava: SomaStrava;
94
98
 
95
99
  constructor(
@@ -100,6 +104,7 @@ export class Soma {
100
104
  this.garminConfig = options?.garmin ?? this.readGarminEnv();
101
105
 
102
106
  this.garmin = new SomaGarmin(this.component, () => this.requireGarminConfig());
107
+ this.healthkit = new SomaHealthKit(this.component);
103
108
  this.strava = new SomaStrava(this.component, () => this.requireStravaConfig());
104
109
  }
105
110
 
@@ -53,6 +53,8 @@ export interface SomaGarminConfig {
53
53
 
54
54
  // ─── Shared Error & Result Types ───────────────────────────────────────────
55
55
 
56
+ export type { SomaErrorType } from "../component/validators/shared.js";
57
+
56
58
  /**
57
59
  * A structured error from a Soma operation.
58
60
  *
@@ -61,8 +63,8 @@ export interface SomaGarminConfig {
61
63
  * (missing connection, nonexistent document) are thrown as exceptions.
62
64
  */
63
65
  export interface SomaError {
64
- /** Category of the failed item (e.g. `"activity"`, `"pushWorkout"`, `"ingest"`). */
65
- type: string;
66
+ /** Category of the failed item see {@link SomaErrorType} for valid values. */
67
+ type: import("../component/validators/shared.js").SomaErrorType;
66
68
  /** Identifier of the failed item, or `"fetch"` for API-level failures. */
67
69
  id: string;
68
70
  /** Human-readable error description. */
@@ -55,6 +55,19 @@ import type * as garmin_types_wellnessApi_client_index from "../garmin/types/wel
55
55
  import type * as garmin_types_wellnessApi_index from "../garmin/types/wellnessApi/index.js";
56
56
  import type * as garmin_utils from "../garmin/utils.js";
57
57
  import type * as garmin_webhooks from "../garmin/webhooks.js";
58
+ import type * as healthkit_index from "../healthkit/index.js";
59
+ import type * as healthkit_transform_activity from "../healthkit/transform/activity.js";
60
+ import type * as healthkit_transform_athlete from "../healthkit/transform/athlete.js";
61
+ import type * as healthkit_transform_body from "../healthkit/transform/body.js";
62
+ import type * as healthkit_transform_daily from "../healthkit/transform/daily.js";
63
+ import type * as healthkit_transform_maps_activityType from "../healthkit/transform/maps/activityType.js";
64
+ import type * as healthkit_transform_maps_menstruationFlow from "../healthkit/transform/maps/menstruationFlow.js";
65
+ import type * as healthkit_transform_maps_sleepLevel from "../healthkit/transform/maps/sleepLevel.js";
66
+ import type * as healthkit_transform_menstruation from "../healthkit/transform/menstruation.js";
67
+ import type * as healthkit_transform_nutrition from "../healthkit/transform/nutrition.js";
68
+ import type * as healthkit_transform_sleep from "../healthkit/transform/sleep.js";
69
+ import type * as healthkit_transform_utils from "../healthkit/transform/utils.js";
70
+ import type * as healthkit_types from "../healthkit/types.js";
58
71
  import type * as private_ from "../private.js";
59
72
  import type * as public_ from "../public.js";
60
73
  import type * as strava_auth from "../strava/auth.js";
@@ -137,6 +150,19 @@ const fullApi: ApiFromModules<{
137
150
  "garmin/types/wellnessApi/index": typeof garmin_types_wellnessApi_index;
138
151
  "garmin/utils": typeof garmin_utils;
139
152
  "garmin/webhooks": typeof garmin_webhooks;
153
+ "healthkit/index": typeof healthkit_index;
154
+ "healthkit/transform/activity": typeof healthkit_transform_activity;
155
+ "healthkit/transform/athlete": typeof healthkit_transform_athlete;
156
+ "healthkit/transform/body": typeof healthkit_transform_body;
157
+ "healthkit/transform/daily": typeof healthkit_transform_daily;
158
+ "healthkit/transform/maps/activityType": typeof healthkit_transform_maps_activityType;
159
+ "healthkit/transform/maps/menstruationFlow": typeof healthkit_transform_maps_menstruationFlow;
160
+ "healthkit/transform/maps/sleepLevel": typeof healthkit_transform_maps_sleepLevel;
161
+ "healthkit/transform/menstruation": typeof healthkit_transform_menstruation;
162
+ "healthkit/transform/nutrition": typeof healthkit_transform_nutrition;
163
+ "healthkit/transform/sleep": typeof healthkit_transform_sleep;
164
+ "healthkit/transform/utils": typeof healthkit_transform_utils;
165
+ "healthkit/types": typeof healthkit_types;
140
166
  private: typeof private_;
141
167
  public: typeof public_;
142
168
  "strava/auth": typeof strava_auth;
@@ -25,6 +25,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
25
25
  {
26
26
  garmin: {
27
27
  public: {
28
+ backfillAll: FunctionReference<
29
+ "action",
30
+ "internal",
31
+ {
32
+ clientId: string;
33
+ clientSecret: string;
34
+ endTimeInSeconds?: number;
35
+ startTimeInSeconds?: number;
36
+ userId: string;
37
+ },
38
+ any,
39
+ Name
40
+ >;
28
41
  completeGarminOAuth: FunctionReference<
29
42
  "action",
30
43
  "internal",
@@ -585,7 +585,7 @@ export const processBodyCompositionsPushPayload = internalAction({
585
585
  if (!connection) {
586
586
  for (const item of userItems) {
587
587
  errors.push({
588
- type: "bodyCompositions",
588
+ type: "body",
589
589
  id: item.summaryId ?? "unknown",
590
590
  message: `No Soma connection found for Garmin userId "${garminUserId}"`,
591
591
  });
@@ -596,7 +596,7 @@ export const processBodyCompositionsPushPayload = internalAction({
596
596
  if (!connection.active) {
597
597
  for (const item of userItems) {
598
598
  errors.push({
599
- type: "bodyCompositions",
599
+ type: "body",
600
600
  id: item.summaryId ?? "unknown",
601
601
  message: `Garmin connection for userId "${garminUserId}" is inactive`,
602
602
  });
@@ -611,7 +611,7 @@ export const processBodyCompositionsPushPayload = internalAction({
611
611
  items.push({ connectionId: connection._id, userId: connection.userId, data });
612
612
  } catch (err) {
613
613
  errors.push({
614
- type: "bodyCompositions",
614
+ type: "body",
615
615
  id: item.summaryId ?? "unknown",
616
616
  message: err instanceof Error ? err.message : String(err),
617
617
  });
@@ -677,7 +677,7 @@ export const processDailiesPushPayload = internalAction({
677
677
  if (!connection) {
678
678
  for (const item of userItems) {
679
679
  errors.push({
680
- type: "dailies",
680
+ type: "daily",
681
681
  id: item.summaryId ?? "unknown",
682
682
  message: `No Soma connection found for Garmin userId "${garminUserId}"`,
683
683
  });
@@ -688,7 +688,7 @@ export const processDailiesPushPayload = internalAction({
688
688
  if (!connection.active) {
689
689
  for (const item of userItems) {
690
690
  errors.push({
691
- type: "dailies",
691
+ type: "daily",
692
692
  id: item.summaryId ?? "unknown",
693
693
  message: `Garmin connection for userId "${garminUserId}" is inactive`,
694
694
  });
@@ -703,7 +703,7 @@ export const processDailiesPushPayload = internalAction({
703
703
  items.push({ connectionId: connection._id, userId: connection.userId, data });
704
704
  } catch (err) {
705
705
  errors.push({
706
- type: "dailies",
706
+ type: "daily",
707
707
  id: item.summaryId ?? "unknown",
708
708
  message: err instanceof Error ? err.message : String(err),
709
709
  });
@@ -861,7 +861,7 @@ export const processHRVSummaryPushPayload = internalAction({
861
861
  if (!connection) {
862
862
  for (const item of userItems) {
863
863
  errors.push({
864
- type: "hrvSummary",
864
+ type: "hrv",
865
865
  id: item.summaryId ?? "unknown",
866
866
  message: `No Soma connection found for Garmin userId "${garminUserId}"`,
867
867
  });
@@ -872,7 +872,7 @@ export const processHRVSummaryPushPayload = internalAction({
872
872
  if (!connection.active) {
873
873
  for (const item of userItems) {
874
874
  errors.push({
875
- type: "hrvSummary",
875
+ type: "hrv",
876
876
  id: item.summaryId ?? "unknown",
877
877
  message: `Garmin connection for userId "${garminUserId}" is inactive`,
878
878
  });
@@ -887,7 +887,7 @@ export const processHRVSummaryPushPayload = internalAction({
887
887
  items.push({ connectionId: connection._id, userId: connection.userId, data });
888
888
  } catch (err) {
889
889
  errors.push({
890
- type: "hrvSummary",
890
+ type: "hrv",
891
891
  id: item.summaryId ?? "unknown",
892
892
  message: err instanceof Error ? err.message : String(err),
893
893
  });
@@ -1594,7 +1594,7 @@ export const processMenstrualCycleTrackingPushPayload = internalAction({
1594
1594
  if (!connection) {
1595
1595
  for (const item of userItems) {
1596
1596
  errors.push({
1597
- type: "menstrualCycleTracking",
1597
+ type: "menstruation",
1598
1598
  id: item.summaryId ?? "unknown",
1599
1599
  message: `No Soma connection found for Garmin userId "${garminUserId}"`,
1600
1600
  });
@@ -1605,7 +1605,7 @@ export const processMenstrualCycleTrackingPushPayload = internalAction({
1605
1605
  if (!connection.active) {
1606
1606
  for (const item of userItems) {
1607
1607
  errors.push({
1608
- type: "menstrualCycleTracking",
1608
+ type: "menstruation",
1609
1609
  id: item.summaryId ?? "unknown",
1610
1610
  message: `Garmin connection for userId "${garminUserId}" is inactive`,
1611
1611
  });
@@ -1619,7 +1619,7 @@ export const processMenstrualCycleTrackingPushPayload = internalAction({
1619
1619
  items.push({ connectionId: connection._id, userId: connection.userId, data });
1620
1620
  } catch (err) {
1621
1621
  errors.push({
1622
- type: "menstrualCycleTracking",
1622
+ type: "menstruation",
1623
1623
  id: item.summaryId ?? "unknown",
1624
1624
  message: err instanceof Error ? err.message : String(err),
1625
1625
  });
@@ -18,7 +18,7 @@ import {
18
18
  createWellnessClient,
19
19
  createTrainingClient,
20
20
  } from "./client.js";
21
- import { buildTimeRangeQuery } from "./utils.js";
21
+ import { buildTimeRangeQuery, buildChunkedTimeRangeQueries, buildBackfillQuery, buildChunkedBackfillQueries } from "./utils.js";
22
22
  import {
23
23
  createWorkoutV2 as sdkCreateWorkoutV2,
24
24
  createWorkoutSchedule as sdkCreateWorkoutSchedule,
@@ -42,6 +42,19 @@ import {
42
42
  getStressDetails,
43
43
  getPulseox,
44
44
  getRespiration,
45
+ getBackfillDailies,
46
+ getBackfillSleeps,
47
+ getBackfillBodycomps,
48
+ getBackfillMct,
49
+ getBackfillBloodPressures,
50
+ getBackfillSkinTemp,
51
+ getBackfillUserMetrics,
52
+ getBackfillHrv,
53
+ getBackfillStressDetails,
54
+ getBackfillPulseox,
55
+ getBackfillRespirationEpoch,
56
+ getBackfillActivities,
57
+ getBackfillActivityDetails,
45
58
  } from "./types/wellnessApi/sdk.gen";
46
59
  import { transformActivity } from "./transform/activity.js";
47
60
  import { transformDailies } from "./transform/dailies.js";
@@ -57,7 +70,7 @@ import { transformPulseOx } from "./transform/pulseOx.js";
57
70
  import { transformRespiration } from "./transform/respiration.js";
58
71
  import { transformPlannedWorkoutToGarmin } from "./transform/plannedWorkout.js";
59
72
  import { api, internal } from "../_generated/api";
60
- import type { SomaError } from "../validators/shared.js";
73
+ import type { SomaError, SomaErrorType } from "../validators/shared.js";
61
74
 
62
75
  // ─── OAuth ──────────────────────────────────────────────────────────────────
63
76
 
@@ -187,8 +200,8 @@ export const disconnectGarmin = action({
187
200
  try {
188
201
  const wellnessClient = createWellnessClient(tokenDoc.accessToken);
189
202
  await sdkDereg({ client: wellnessClient });
190
- } catch {
191
- // Deregistration is best-effort; proceed with local cleanup
203
+ } catch (err) {
204
+ console.warn("[garmin:disconnect] Best-effort deregistration failed:", err instanceof Error ? err.message : err);
192
205
  }
193
206
  }
194
207
 
@@ -507,11 +520,11 @@ export const pullSkinTemperature = action({
507
520
  await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
508
521
  synced.skinTemp++;
509
522
  } catch (err) {
510
- errors.push({ type: "skinTemp", id: skin.summaryId ?? skin.calendarDate ?? "unknown", message: err instanceof Error ? err.message : String(err) });
523
+ errors.push({ type: "skinTemperature", id: skin.summaryId ?? skin.calendarDate ?? "unknown", message: err instanceof Error ? err.message : String(err) });
511
524
  }
512
525
  }
513
526
  } catch (err) {
514
- errors.push({ type: "skinTemp", id: "fetch", message: err instanceof Error ? err.message : String(err) });
527
+ errors.push({ type: "skinTemperature", id: "fetch", message: err instanceof Error ? err.message : String(err) });
515
528
  }
516
529
 
517
530
  await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
@@ -740,14 +753,15 @@ export const pullAll = action({
740
753
  startTimeInSeconds: args.startTimeInSeconds,
741
754
  endTimeInSeconds: args.endTimeInSeconds,
742
755
  };
743
- const pullFns = [
744
- { ref: api.garmin.public.pullActivities, name: "activities" },
745
- { ref: api.garmin.public.pullDailies, name: "dailies" },
756
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
757
+ const pullFns: Array<{ ref: any; name: SomaErrorType }> = [
758
+ { ref: api.garmin.public.pullActivities, name: "activity" },
759
+ { ref: api.garmin.public.pullDailies, name: "daily" },
746
760
  { ref: api.garmin.public.pullSleep, name: "sleep" },
747
761
  { ref: api.garmin.public.pullBody, name: "body" },
748
762
  { ref: api.garmin.public.pullMenstruation, name: "menstruation" },
749
- { ref: api.garmin.public.pullBloodPressures, name: "bloodPressures" },
750
- { ref: api.garmin.public.pullSkinTemperature, name: "skinTemp" },
763
+ { ref: api.garmin.public.pullBloodPressures, name: "bloodPressure" },
764
+ { ref: api.garmin.public.pullSkinTemperature, name: "skinTemperature" },
751
765
  { ref: api.garmin.public.pullUserMetrics, name: "userMetrics" },
752
766
  { ref: api.garmin.public.pullHRV, name: "hrv" },
753
767
  { ref: api.garmin.public.pullStressDetails, name: "stressDetails" },
@@ -774,6 +788,147 @@ export const pullAll = action({
774
788
  });
775
789
 
776
790
 
791
+ // ─── Backfill ─────────────────────────────────────────────────────────────
792
+
793
+ /**
794
+ * Request historical data from Garmin's backfill API for all summary types.
795
+ *
796
+ * Unlike pull functions (which query by upload time with a 24h max window),
797
+ * backfill requests data by the time it was *recorded* on the device.
798
+ * Garmin processes backfill asynchronously (HTTP 202) and delivers data
799
+ * via push/ping webhooks — no data is returned synchronously.
800
+ *
801
+ * Health endpoints accept up to 90 days per request.
802
+ * Activity endpoints accept up to 30 days per request (auto-chunked).
803
+ *
804
+ * Defaults to the last 90 days when no time range is provided.
805
+ */
806
+ export const backfillAll = action({
807
+ args: {
808
+ userId: v.string(),
809
+ clientId: v.string(),
810
+ clientSecret: v.string(),
811
+ startTimeInSeconds: v.optional(v.number()),
812
+ endTimeInSeconds: v.optional(v.number()),
813
+ },
814
+ handler: async (ctx, args) => {
815
+ const { connectionId, accessToken } = await ctx.runAction(
816
+ internal.private.resolveConnectionAndAccessToken,
817
+ {
818
+ userId: args.userId,
819
+ provider: "GARMIN",
820
+ clientId: args.clientId,
821
+ clientSecret: args.clientSecret,
822
+ },
823
+ );
824
+
825
+ const wellnessClient = createWellnessClient(accessToken);
826
+ const timeInput = {
827
+ startTimeInSeconds: args.startTimeInSeconds,
828
+ endTimeInSeconds: args.endTimeInSeconds,
829
+ };
830
+
831
+ // Health endpoints: up to 90 days, single request each
832
+ const healthQuery = buildBackfillQuery(timeInput);
833
+
834
+ // Activity endpoints: max 30 days per request, chunked
835
+ const activityChunks = buildChunkedBackfillQueries(timeInput);
836
+
837
+ const requested: string[] = [];
838
+ const errors: SomaError[] = [];
839
+
840
+ // ── Health backfills (90-day window) ──
841
+ const healthBackfills: Array<{
842
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
843
+ fn: (opts: any) => Promise<any>;
844
+ name: string;
845
+ }> = [
846
+ { fn: getBackfillDailies, name: "dailies" },
847
+ { fn: getBackfillSleeps, name: "sleeps" },
848
+ { fn: getBackfillBodycomps, name: "bodyComps" },
849
+ { fn: getBackfillMct, name: "mct" },
850
+ { fn: getBackfillBloodPressures, name: "bloodPressures" },
851
+ { fn: getBackfillSkinTemp, name: "skinTemp" },
852
+ { fn: getBackfillUserMetrics, name: "userMetrics" },
853
+ { fn: getBackfillHrv, name: "hrv" },
854
+ { fn: getBackfillStressDetails, name: "stressDetails" },
855
+ { fn: getBackfillPulseox, name: "pulseOx" },
856
+ { fn: getBackfillRespirationEpoch, name: "respiration" },
857
+ ];
858
+
859
+ for (const { fn, name } of healthBackfills) {
860
+ try {
861
+ const { error } = await fn({
862
+ client: wellnessClient,
863
+ query: healthQuery,
864
+ });
865
+ if (error) {
866
+ errors.push({
867
+ type: name as SomaErrorType,
868
+ id: "backfill",
869
+ message: JSON.stringify(error),
870
+ });
871
+ } else {
872
+ requested.push(name);
873
+ }
874
+ } catch (err) {
875
+ errors.push({
876
+ type: name as SomaErrorType,
877
+ id: "backfill",
878
+ message: err instanceof Error ? err.message : String(err),
879
+ });
880
+ }
881
+ }
882
+
883
+ // ── Activity backfills (30-day chunked windows) ──
884
+ const activityBackfills: Array<{
885
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
886
+ fn: (opts: any) => Promise<any>;
887
+ name: string;
888
+ }> = [
889
+ { fn: getBackfillActivities, name: "activities" },
890
+ { fn: getBackfillActivityDetails, name: "activityDetails" },
891
+ ];
892
+
893
+ for (const { fn, name } of activityBackfills) {
894
+ let chunkErrors = 0;
895
+ for (const chunk of activityChunks) {
896
+ try {
897
+ const { error } = await fn({
898
+ client: wellnessClient,
899
+ query: chunk,
900
+ });
901
+ if (error) {
902
+ chunkErrors++;
903
+ errors.push({
904
+ type: name as SomaErrorType,
905
+ id: "backfill",
906
+ message: JSON.stringify(error),
907
+ });
908
+ }
909
+ } catch (err) {
910
+ chunkErrors++;
911
+ errors.push({
912
+ type: name as SomaErrorType,
913
+ id: "backfill",
914
+ message: err instanceof Error ? err.message : String(err),
915
+ });
916
+ }
917
+ }
918
+ if (chunkErrors < activityChunks.length) {
919
+ requested.push(name);
920
+ }
921
+ }
922
+
923
+ await ctx.runMutation(api.public.updateConnection, {
924
+ connectionId,
925
+ lastDataUpdate: new Date().toISOString(),
926
+ });
927
+
928
+ return { data: { requested }, errors };
929
+ },
930
+ });
931
+
777
932
  // ─── Push ───────────────────────────────────────────────────────────────────
778
933
 
779
934
  export const pushWorkout = action({
@@ -39,4 +39,106 @@ export function buildTimeRangeQuery(
39
39
  uploadEndTimeInSeconds: String(uploadEndTimeInSeconds),
40
40
  token: accessToken,
41
41
  };
42
+ }
43
+
44
+ /**
45
+ * Build an array of time-range query objects, each spanning at most
46
+ * `maxRangeSeconds`.
47
+ *
48
+ * Garmin's intraday endpoints (respiration, stress, pulse-ox, epochs, HRV)
49
+ * enforce a maximum query window of 86 400 s (24 h). This helper splits the
50
+ * full requested range into consecutive, non-overlapping chunks so the caller
51
+ * can issue one API call per chunk and merge the results.
52
+ */
53
+ export function buildChunkedTimeRangeQueries(
54
+ input: { startTimeInSeconds?: number; endTimeInSeconds?: number },
55
+ accessToken: string,
56
+ maxRangeSeconds: number,
57
+ nowSeconds = Math.floor(Date.now() / 1000),
58
+ defaultSyncDays = 30,
59
+ ) {
60
+ const start =
61
+ input.startTimeInSeconds ?? nowSeconds - defaultSyncDays * 86400;
62
+ const end = input.endTimeInSeconds ?? nowSeconds;
63
+
64
+ const chunks: Array<{
65
+ uploadStartTimeInSeconds: string;
66
+ uploadEndTimeInSeconds: string;
67
+ token: string;
68
+ }> = [];
69
+
70
+ for (let chunkStart = start; chunkStart < end; chunkStart += maxRangeSeconds) {
71
+ const chunkEnd = Math.min(chunkStart + maxRangeSeconds, end);
72
+ chunks.push({
73
+ uploadStartTimeInSeconds: String(chunkStart),
74
+ uploadEndTimeInSeconds: String(chunkEnd),
75
+ token: accessToken,
76
+ });
77
+ }
78
+
79
+ return chunks;
80
+ }
81
+
82
+ // ─── Backfill Query Helpers ────────────────────────────────────────────────
83
+
84
+ const BACKFILL_MAX_HEALTH_DAYS = 90;
85
+ const BACKFILL_MAX_ACTIVITY_DAYS = 30;
86
+
87
+ /**
88
+ * Build a backfill query object for Garmin's `/rest/backfill/*` endpoints.
89
+ *
90
+ * Backfill uses `summaryStartTimeInSeconds` / `summaryEndTimeInSeconds`
91
+ * (based on when data was *recorded*, not uploaded) and does NOT take a
92
+ * `token` query param — auth is via Bearer header only.
93
+ *
94
+ * Health endpoints accept up to 90 days per request.
95
+ * Activity endpoints accept up to 30 days per request.
96
+ */
97
+ export function buildBackfillQuery(
98
+ input: { startTimeInSeconds?: number; endTimeInSeconds?: number },
99
+ nowSeconds = Math.floor(Date.now() / 1000),
100
+ defaultDays = BACKFILL_MAX_HEALTH_DAYS,
101
+ ) {
102
+ const summaryStartTimeInSeconds =
103
+ input.startTimeInSeconds ?? nowSeconds - defaultDays * 86400;
104
+ const summaryEndTimeInSeconds = input.endTimeInSeconds ?? nowSeconds;
105
+
106
+ return {
107
+ summaryStartTimeInSeconds: String(summaryStartTimeInSeconds),
108
+ summaryEndTimeInSeconds: String(summaryEndTimeInSeconds),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Build an array of backfill query objects, each spanning at most
114
+ * `maxDays` days.
115
+ *
116
+ * Activity backfill endpoints enforce a 30-day max per request.
117
+ * This splits a wider range into consecutive, non-overlapping chunks.
118
+ */
119
+ export function buildChunkedBackfillQueries(
120
+ input: { startTimeInSeconds?: number; endTimeInSeconds?: number },
121
+ maxDays = BACKFILL_MAX_ACTIVITY_DAYS,
122
+ nowSeconds = Math.floor(Date.now() / 1000),
123
+ defaultDays = BACKFILL_MAX_HEALTH_DAYS,
124
+ ) {
125
+ const start =
126
+ input.startTimeInSeconds ?? nowSeconds - defaultDays * 86400;
127
+ const end = input.endTimeInSeconds ?? nowSeconds;
128
+
129
+ const maxRangeSeconds = maxDays * 86400;
130
+ const chunks: Array<{
131
+ summaryStartTimeInSeconds: string;
132
+ summaryEndTimeInSeconds: string;
133
+ }> = [];
134
+
135
+ for (let chunkStart = start; chunkStart < end; chunkStart += maxRangeSeconds) {
136
+ const chunkEnd = Math.min(chunkStart + maxRangeSeconds, end);
137
+ chunks.push({
138
+ summaryStartTimeInSeconds: String(chunkStart),
139
+ summaryEndTimeInSeconds: String(chunkEnd),
140
+ });
141
+ }
142
+
143
+ return chunks;
42
144
  }
@@ -6,7 +6,7 @@
6
6
  import { v } from "convex/values";
7
7
  import { action, type ActionCtx } from "../_generated/server";
8
8
  import { api, internal } from "../_generated/api";
9
- import type { SomaError } from "../validators/shared.js";
9
+ import type { SomaError, WebhookResult } from "../validators/shared.js";
10
10
  import {
11
11
  garminSkinTemperaturePingPayloadSchema,
12
12
  garminSkinTemperaturePushPayloadSchema,
@@ -104,12 +104,6 @@ function isWebhookPushMode(payload: unknown): boolean {
104
104
  return !("callbackURL" in firstItem);
105
105
  }
106
106
 
107
- /** Shape returned by webhook handler actions (both internal processors and public handlers). */
108
- type WebhookResult = {
109
- errors: SomaError[];
110
- items: Array<{ connectionId: string; userId: string; data: Record<string, unknown> }>;
111
- };
112
-
113
107
  /**
114
108
  * Ingest transformed items from a private handler and update connections.
115
109
  * Shared orchestration logic for all push-mode webhook handlers.