musora-content-services 2.160.3 → 2.160.5

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 (42) hide show
  1. package/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
  2. package/.claude/settings.local.json +23 -0
  3. package/CHANGELOG.md +17 -0
  4. package/package.json +1 -1
  5. package/src/contentTypeConfig.js +14 -15
  6. package/src/index.d.ts +6 -2
  7. package/src/index.js +6 -2
  8. package/src/infrastructure/sanity/README.md +230 -0
  9. package/src/infrastructure/sanity/SanityClient.ts +105 -0
  10. package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
  11. package/src/infrastructure/sanity/examples/usage.ts +101 -0
  12. package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
  13. package/src/infrastructure/sanity/index.ts +19 -0
  14. package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
  15. package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
  16. package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
  17. package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
  18. package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
  19. package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
  20. package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
  21. package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
  22. package/src/lib/sanity/decorators/base.ts +142 -0
  23. package/src/lib/sanity/decorators/examples.ts +229 -0
  24. package/src/lib/sanity/decorators/navigate-to.ts +139 -0
  25. package/src/lib/sanity/decorators/need-access.ts +40 -0
  26. package/src/lib/sanity/decorators/page-type.ts +35 -0
  27. package/src/services/awards/award-query.js +71 -0
  28. package/src/services/contentAggregator.js +1 -1
  29. package/src/services/multi-user-accounts/multi-user-accounts.ts +2 -0
  30. package/src/services/sanity.js +2 -2
  31. package/src/services/user/profile.ts +66 -0
  32. package/test/live/content.test.js +116 -0
  33. package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
  34. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
  35. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
  36. package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
  37. package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
  38. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
  39. package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
  40. package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
  41. package/test/unit/sanityQueryService.test.ts +11 -0
  42. package/src/services/user/profile.js +0 -43
@@ -0,0 +1,23 @@
1
+ ---
2
+ date: 2026-05-20
3
+ branch: fix/live-event-fetch-permissions-id
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/982
5
+ status: open
6
+ tags: [bug-fix]
7
+ ---
8
+
9
+ # Include permission_id in live event minimum fields
10
+
11
+ ## Context
12
+ Live event consumers downstream of `getLiveFields()` need `permission_id` to resolve access to events. Without it on the returned payload, callers had to issue an extra query (or fall back to defaults) to determine whether the current user is permitted to view a given live event.
13
+
14
+ ## Decision
15
+ Add `'permission_id'` to the `minimumFields` array in `getLiveFields()` in `src/contentTypeConfig.js` so the field is projected from Sanity alongside the other core live event fields (`live_event_start_time`, `live_event_end_time`, `live_event_stream_id`, `vimeo_live_event_id`, etc.).
16
+
17
+ ## Alternatives Considered
18
+ - Put `permission_id` in `additionalFields` instead of `minimumFields`. Rejected — callers requesting the minimum projection are the ones that gate playback, so they need permission resolution by default.
19
+ - Fetch `permission_id` in a separate query at the consumer layer. Rejected — duplicates Sanity round-trips and spreads access logic.
20
+
21
+ ## Consequences
22
+ - All live event responses produced via `getLiveFields()` now include `permission_id`.
23
+ - Incidental formatting (single-quote and trailing-comma normalization) was applied to the same file by the editor — not behavioral.
@@ -0,0 +1,23 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx jest *)",
5
+ "Bash(npx tsc *)",
6
+ "Skill(counselors)",
7
+ "Bash(counselors ls *)",
8
+ "Bash(counselors groups *)",
9
+ "Bash(counselors run *)",
10
+ "Bash(npm test *)",
11
+ "Bash(gh pr *)",
12
+ "Bash(gh api *)",
13
+ "Bash(mkdir -p /tmp/pr-review-v2)",
14
+ "Read(//tmp/pr-review-v2/**)",
15
+ "Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
16
+ "Bash(echo \"no AGENTS.md\")",
17
+ "Bash(echo \"exit=$?\")",
18
+ "Bash(git checkout *)",
19
+ "Skill(pr)",
20
+ "Skill(create-decision)"
21
+ ]
22
+ }
23
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.160.5](https://github.com/railroadmedia/musora-content-services/compare/v2.160.4...v2.160.5) (2026-05-22)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * add a null check for method intro video ([#983](https://github.com/railroadmedia/musora-content-services/issues/983)) ([dbcaa72](https://github.com/railroadmedia/musora-content-services/commit/dbcaa7250ccca71ddcefda0d0514a7a013055784))
11
+ * **live-events:** fetch permission ids ([#982](https://github.com/railroadmedia/musora-content-services/issues/982)) ([cd12054](https://github.com/railroadmedia/musora-content-services/commit/cd12054a58a0752614f8cdebcf79750edae948c3))
12
+
13
+ ### [2.160.4](https://github.com/railroadmedia/musora-content-services/compare/v2.160.3...v2.160.4) (2026-05-20)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **MU2-1510:** process need access for live events ([#964](https://github.com/railroadmedia/musora-content-services/issues/964)) ([66aa2b5](https://github.com/railroadmedia/musora-content-services/commit/66aa2b596b28c272b938672522b9c4024f9e328d))
19
+ * **MU2-1511:** add need access to upcoming live events ([#965](https://github.com/railroadmedia/musora-content-services/issues/965)) ([9366907](https://github.com/railroadmedia/musora-content-services/commit/936690752529d25d80df0192e80b57ad90921445))
20
+ * **MU2-1512:** add child data to new and upcoming ([#960](https://github.com/railroadmedia/musora-content-services/issues/960)) ([329f5c8](https://github.com/railroadmedia/musora-content-services/commit/329f5c8b98ea11f1d456dd53e81b705f55b4f346))
21
+
5
22
  ### [2.160.3](https://github.com/railroadmedia/musora-content-services/compare/v2.160.2...v2.160.3) (2026-05-14)
6
23
 
7
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.160.3",
3
+ "version": "2.160.5",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -146,22 +146,23 @@ export const assignmentsField = `"assignments":assignment[]{
146
146
  // todo: refactor live event queries to use this
147
147
  export function getLiveFields(minimum = false) {
148
148
  const minimumFields = [
149
- "live_event_start_time",
150
- "live_event_end_time",
151
- "live_event_stream_id",
152
- "vimeo_live_event_id",
149
+ 'live_event_start_time',
150
+ 'live_event_end_time',
151
+ 'live_event_stream_id',
152
+ 'vimeo_live_event_id',
153
153
  "'live_event_is_global': live_global_event == true",
154
154
  "'videoId': coalesce(live_event_stream_id, video.external_id)",
155
+ "'permission_id': permission_v2",
155
156
  ]
156
157
  const additionalFields = [
157
158
  "'slug': slug.current",
158
159
  "'id': railcontent_id",
159
- "title",
160
- "published_on",
160
+ 'title',
161
+ 'published_on',
161
162
  "'thumbnail': thumbnail.asset->url",
162
163
  `${artistOrInstructorName()}`,
163
- "difficulty_string",
164
- "railcontent_id",
164
+ 'difficulty_string',
165
+ 'railcontent_id',
165
166
  `'instructors': ${instructorField}`,
166
167
  ]
167
168
 
@@ -694,10 +695,8 @@ export let contentTypeConfig = {
694
695
  }`,
695
696
  ],
696
697
  'new-and-scheduled': {
697
- fields: [
698
- 'show_in_new_feed',
699
- isLiveField(),
700
- ],
698
+ fields: ['show_in_new_feed', isLiveField()],
699
+ includeChildFields: true,
701
700
  },
702
701
  }
703
702
 
@@ -815,7 +814,7 @@ export function artistOrInstructorNameAsArray(key = 'artists') {
815
814
 
816
815
  export async function getFieldsForContentTypeWithFilteredChildren(
817
816
  contentType,
818
- asQueryString = true,
817
+ asQueryString = true
819
818
  ) {
820
819
  const childFields = getChildFieldsForContentType(contentType, true)
821
820
  const parentFields = getFieldsForContentType(contentType, false)
@@ -830,7 +829,7 @@ export async function getFieldsForContentTypeWithFilteredChildren(
830
829
  "children": child[${childFilter}]->{
831
830
  ${childFields}
832
831
  },
833
- }`,
832
+ }`
834
833
  )
835
834
  }
836
835
  return asQueryString ? parentFields.toString() + ',' : parentFields
@@ -920,7 +919,7 @@ const filterHandlers = {
920
919
  length: (value) => {
921
920
  // Find the matching length option by name
922
921
  const lengthOption = Object.values(LengthFilterOptions).find(
923
- (opt) => typeof opt === 'object' && opt.name === value,
922
+ (opt) => typeof opt === 'object' && opt.name === value
924
923
  )
925
924
 
926
925
  if (!lengthOption) return ''
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  getAwardStatistics,
10
10
  getBadgeFields,
11
11
  getCompletedAwards,
12
+ getCompletedAwardsByUser,
12
13
  getContentAwards,
13
14
  getContentAwardsByIds,
14
15
  getInProgressAwards,
@@ -455,8 +456,9 @@ import {
455
456
 
456
457
  import {
457
458
  deleteProfilePicture,
458
- otherStats
459
- } from './services/user/profile.js';
459
+ otherStats,
460
+ updateProfileVisibility
461
+ } from './services/user/profile.ts';
460
462
 
461
463
  import {
462
464
  generateAuthSessionUrl,
@@ -665,6 +667,7 @@ declare module 'musora-content-services' {
665
667
  getAwardStatistics,
666
668
  getBadgeFields,
667
669
  getCompletedAwards,
670
+ getCompletedAwardsByUser,
668
671
  getContentAwards,
669
672
  getContentAwardsByIds,
670
673
  getContentRows,
@@ -828,6 +831,7 @@ declare module 'musora-content-services' {
828
831
  updatePlaylist,
829
832
  updatePost,
830
833
  updatePracticeNotes,
834
+ updateProfileVisibility,
831
835
  updateThread,
832
836
  updateUserPractice,
833
837
  upgradeSubscription,
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getAwardStatistics,
14
14
  getBadgeFields,
15
15
  getCompletedAwards,
16
+ getCompletedAwardsByUser,
16
17
  getContentAwards,
17
18
  getContentAwardsByIds,
18
19
  getInProgressAwards,
@@ -459,8 +460,9 @@ import {
459
460
 
460
461
  import {
461
462
  deleteProfilePicture,
462
- otherStats
463
- } from './services/user/profile.js';
463
+ otherStats,
464
+ updateProfileVisibility
465
+ } from './services/user/profile.ts';
464
466
 
465
467
  import {
466
468
  generateAuthSessionUrl,
@@ -664,6 +666,7 @@ export {
664
666
  getAwardStatistics,
665
667
  getBadgeFields,
666
668
  getCompletedAwards,
669
+ getCompletedAwardsByUser,
667
670
  getContentAwards,
668
671
  getContentAwardsByIds,
669
672
  getContentRows,
@@ -827,6 +830,7 @@ export {
827
830
  updatePlaylist,
828
831
  updatePost,
829
832
  updatePracticeNotes,
833
+ updateProfileVisibility,
830
834
  updateThread,
831
835
  updateUserPractice,
832
836
  upgradeSubscription,
@@ -0,0 +1,230 @@
1
+ # Sanity Infrastructure
2
+
3
+ This module provides a TypeScript-based infrastructure for interacting with Sanity CMS, following the same architectural patterns as the HTTP client.
4
+
5
+ ## Architecture
6
+
7
+ The Sanity infrastructure follows a modular design with clear separation of concerns:
8
+
9
+ - **SanityClient**: Base client class that provides low-level methods for executing raw GROQ queries
10
+ - **ContentClient**: Specialized client that extends SanityClient with content-specific methods like `fetchById`
11
+ - **Interfaces**: Define contracts for configuration, queries, responses, and errors
12
+ - **Providers**: Handle configuration management
13
+ - **Executors**: Handle the actual execution of queries against the Sanity API
14
+
15
+ ## Usage
16
+
17
+ ### Basic Usage
18
+
19
+ ```typescript
20
+ import { SanityClient, ContentClient } from './infrastructure/sanity'
21
+
22
+ // Create client instances (use global configuration automatically)
23
+ const sanityClient = new SanityClient() // For raw GROQ queries
24
+ const contentClient = new ContentClient() // For content-specific operations
25
+
26
+ // Fetch a single document by type and ID (recommended approach)
27
+ const song = await contentClient.fetchById({
28
+ type: 'song',
29
+ id: 123
30
+ })
31
+
32
+ // Fetch with custom fields
33
+ const songWithCustomFields = await contentClient.fetchById({
34
+ type: 'song',
35
+ id: 123,
36
+ fields: [
37
+ "'id': railcontent_id",
38
+ 'title',
39
+ "'artist': artist->name",
40
+ 'album',
41
+ 'difficulty_string'
42
+ ]
43
+ })
44
+
45
+ // Fetch with children (for courses, packs, etc.)
46
+ const courseWithLessons = await contentClient.fetchById({
47
+ type: 'course',
48
+ id: 456,
49
+ includeChildren: true
50
+ })
51
+
52
+ // Fetch multiple content items by IDs
53
+ const multipleSongs = await contentClient.fetchByIds([123, 456, 789], 'song', 'drumeo')
54
+
55
+ // Fetch content by brand and type
56
+ const drumeoSongs = await contentClient.fetchByBrandAndType('drumeo', 'song', {
57
+ limit: 20,
58
+ sortBy: 'published_on desc'
59
+ })
60
+
61
+ // Use base SanityClient for raw GROQ queries (for complex queries)
62
+ const songs = await sanityClient.fetchList(`
63
+ *[_type == "song" && brand == "drumeo"] | order(published_on desc)[0...10]{
64
+ "id": railcontent_id,
65
+ title,
66
+ "artist": artist->name
67
+ }
68
+ `)
69
+
70
+ // Execute complex queries with base client
71
+ const result = await sanityClient.executeQuery(`
72
+ {
73
+ "songs": *[_type == "song"][0...5],
74
+ "total": count(*[_type == "song"])
75
+ }
76
+ `)
77
+ ```
78
+
79
+ ### Custom Configuration
80
+
81
+ ```typescript
82
+ import { SanityClient, ContentClient, ConfigProvider, SanityConfig } from './infrastructure/sanity'
83
+
84
+ // Use custom configuration provider
85
+ class CustomConfigProvider implements ConfigProvider {
86
+ getConfig(): SanityConfig {
87
+ return {
88
+ projectId: 'custom-project',
89
+ dataset: 'production',
90
+ version: '2021-06-07',
91
+ token: 'custom-token',
92
+ perspective: 'published',
93
+ useCachedAPI: true,
94
+ debug: false
95
+ }
96
+ }
97
+ }
98
+
99
+ const customConfigProvider = new CustomConfigProvider()
100
+ const sanityClient = new SanityClient(customConfigProvider)
101
+ const contentClient = new ContentClient(customConfigProvider)
102
+ ```
103
+
104
+ ### Error Handling
105
+
106
+ ```typescript
107
+ try {
108
+ const result = await sanityClient.fetchSingle('*[_type == "song"][0]')
109
+ } catch (error) {
110
+ if (error.query) {
111
+ console.error('Query failed:', error.query)
112
+ console.error('Error:', error.message)
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## Configuration
118
+
119
+ The SanityClient uses the global configuration from the config service by default. Ensure your application is initialized with proper Sanity configuration:
120
+
121
+ ```javascript
122
+ import { initializeService } from './services/config'
123
+
124
+ initializeService({
125
+ sanityConfig: {
126
+ token: 'your-sanity-api-token',
127
+ projectId: 'your-sanity-project-id',
128
+ dataset: 'your-dataset-name',
129
+ version: '2021-06-07',
130
+ perspective: 'published',
131
+ useCachedAPI: false,
132
+ debug: false
133
+ },
134
+ // ... other config
135
+ })
136
+ ```
137
+
138
+ ## API Reference
139
+
140
+ ### SanityClient (Base Client)
141
+
142
+ #### Methods
143
+
144
+ - `fetchSingle<T>(query: string, params?: Record<string, any>): Promise<T | null>`
145
+ - Executes a query and returns the first result
146
+
147
+ - `fetchList<T>(query: string, params?: Record<string, any>): Promise<T[]>`
148
+ - Executes a query and returns all results as an array
149
+
150
+ - `executeQuery<T>(query: string, params?: Record<string, any>): Promise<T | null>`
151
+ - Executes a raw query and returns the full response
152
+
153
+ - `refreshConfig(): void`
154
+ - Refreshes the configuration (useful if global config changes)
155
+
156
+ ### ContentClient (Specialized Client)
157
+
158
+ #### Methods
159
+
160
+ - `fetchById<T>(options: FetchByIdOptions): Promise<T | null>`
161
+ - Fetches a single document by type and ID (recommended for simple lookups)
162
+ - Options: `{ type: string, id: number | string, fields?: string[], includeChildren?: boolean }`
163
+
164
+ - `fetchByIds<T>(ids: (number | string)[], type?: string, brand?: string, fields?: string[]): Promise<T[]>`
165
+ - Fetches multiple content items by their IDs
166
+ - Results are sorted to match the order of input IDs
167
+
168
+ - `fetchByBrandAndType<T>(brand: string, type: string, options?: {...}): Promise<T[]>`
169
+ - Fetches content by brand and type with basic filtering
170
+ - Options: `{ limit?: number, offset?: number, sortBy?: string, fields?: string[] }`
171
+
172
+ - All methods from SanityClient (inherited)
173
+
174
+ ### Interfaces
175
+
176
+ - `SanityConfig`: Configuration structure for Sanity connection
177
+ - `SanityQuery`: Structure for GROQ queries
178
+ - `SanityResponse<T>`: Structure for Sanity API responses
179
+ - `SanityError`: Structure for Sanity-specific errors
180
+ - `QueryExecutor`: Interface for query execution implementations
181
+ - `ConfigProvider`: Interface for configuration providers
182
+
183
+ ## Examples
184
+
185
+ See the `examples/usage.ts` file for comprehensive usage examples.
186
+
187
+ ## Migration from Legacy Code
188
+
189
+ To migrate from the existing `fetchByRailContentId` function:
190
+
191
+ ```typescript
192
+ // Old way
193
+ import { fetchByRailContentId } from './services/sanity'
194
+ const result = await fetchByRailContentId(123, 'song')
195
+
196
+ // New way (recommended)
197
+ import { ContentClient } from './infrastructure/sanity'
198
+ const contentClient = new ContentClient()
199
+ const result = await contentClient.fetchById({ type: 'song', id: 123 })
200
+ ```
201
+
202
+ To migrate from the existing `fetchByRailContentIds` function:
203
+
204
+ ```typescript
205
+ // Old way
206
+ import { fetchByRailContentIds } from './services/sanity'
207
+ const results = await fetchByRailContentIds([123, 456, 789], 'song', 'drumeo')
208
+
209
+ // New way
210
+ import { ContentClient } from './infrastructure/sanity'
211
+ const contentClient = new ContentClient()
212
+ const results = await contentClient.fetchByIds([123, 456, 789], 'song', 'drumeo')
213
+ ```
214
+
215
+ To migrate from the existing `fetchSanity` function:
216
+
217
+ ```typescript
218
+ // Old way
219
+ import { fetchSanity } from './services/sanity'
220
+ const result = await fetchSanity(query, isList)
221
+
222
+ // New way
223
+ import { SanityClient } from './infrastructure/sanity'
224
+ const sanityClient = new SanityClient()
225
+ const result = isList
226
+ ? await sanityClient.fetchList(query)
227
+ : await sanityClient.fetchSingle(query)
228
+ ```
229
+
230
+ The new architecture provides better type safety, error handling, separation of concerns, and follows modern architectural patterns.
@@ -0,0 +1,105 @@
1
+ import { ConfigProvider } from './interfaces/ConfigProvider'
2
+ import { QueryExecutor } from './interfaces/QueryExecutor'
3
+ import { SanityQuery } from './interfaces/SanityQuery'
4
+ import { SanityConfig } from './interfaces/SanityConfig'
5
+ import { SanityError } from './interfaces/SanityError'
6
+ import { DefaultConfigProvider } from './providers/DefaultConfigProvider'
7
+ import { FetchQueryExecutor } from './executors/FetchQueryExecutor'
8
+
9
+ export class SanityClient {
10
+ private configProvider: ConfigProvider
11
+ private queryExecutor: QueryExecutor
12
+ private config: SanityConfig | null = null
13
+
14
+ constructor(
15
+ configProvider: ConfigProvider = new DefaultConfigProvider(),
16
+ queryExecutor: QueryExecutor = new FetchQueryExecutor()
17
+ ) {
18
+ this.configProvider = configProvider
19
+ this.queryExecutor = queryExecutor
20
+ }
21
+
22
+ /**
23
+ * Execute a GROQ query and return a single result
24
+ */
25
+ public async fetchSingle<T>(query: string, params?: Record<string, any>): Promise<T | null> {
26
+ try {
27
+ const sanityQuery: SanityQuery = { query, params }
28
+ const response = await this.queryExecutor.execute<T[]>(sanityQuery, this.getConfig())
29
+
30
+ if (response.result && Array.isArray(response.result) && response.result.length > 0) {
31
+ return response.result[0]
32
+ }
33
+
34
+ return null
35
+ } catch (error: any) {
36
+ this.handleError(error, query)
37
+ return null
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Execute a GROQ query and return multiple results
43
+ */
44
+ public async fetchList<T>(query: string, params?: Record<string, any>): Promise<T[]> {
45
+ try {
46
+ const sanityQuery: SanityQuery = { query, params }
47
+ const response = await this.queryExecutor.execute<T[]>(sanityQuery, this.getConfig())
48
+
49
+ return response.result || []
50
+ } catch (error: any) {
51
+ this.handleError(error, query)
52
+ return []
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Execute a raw GROQ query and return the full response
58
+ */
59
+ public async executeQuery<T>(query: string, params?: Record<string, any>): Promise<T | null> {
60
+ try {
61
+ const sanityQuery: SanityQuery = { query, params }
62
+ const response = await this.queryExecutor.execute<T>(sanityQuery, this.getConfig())
63
+
64
+ return response.result
65
+ } catch (error: any) {
66
+ this.handleError(error, query)
67
+ return null
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Get configuration, loading it if necessary
73
+ */
74
+ private getConfig(): SanityConfig {
75
+ if (!this.config) {
76
+ this.config = this.configProvider.getConfig()
77
+ }
78
+ return this.config
79
+ }
80
+
81
+ /**
82
+ * Handle and rethrow errors
83
+ */
84
+ private handleError(error: any, query: string): never {
85
+ if ('message' in error && 'query' in error) {
86
+ // This is already a SanityError
87
+ throw error as SanityError
88
+ }
89
+
90
+ // Convert to SanityError
91
+ throw {
92
+ message: error.message || 'Sanity query failed',
93
+ query,
94
+ originalError: error,
95
+ } as SanityError
96
+ }
97
+
98
+ /**
99
+ * Refresh the configuration (useful if global config changes)
100
+ */
101
+ public refreshConfig(): void {
102
+ this.config = null
103
+ }
104
+ }
105
+