musora-content-services 2.179.0 → 2.180.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.
@@ -0,0 +1,37 @@
1
+ ---
2
+ date: 2026-09-04
3
+ branch: refactor/sanity-client-cloudflare-cache
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/1046
5
+ status: open
6
+ tags: [[chore]]
7
+ ---
8
+
9
+ # Route SanityClient through Cloudflare caching proxy
10
+
11
+ ## Context
12
+
13
+ PR #900 added a Cloudflare caching layer for Sanity queries by pointing the legacy `fetchSanity()` function (`src/services/sanity.js`) at `sanity.musora.com` instead of hitting `*.sanity.io` directly. That change only touched the legacy path. The newer `SanityClient` class (`src/infrastructure/sanity/SanityClient.ts`) builds its request URL independently in `FetchQueryExecutor.buildUrl()`, which still hit `*.sanity.io` directly and bypassed the CF cache entirely.
14
+
15
+ ## Decision
16
+
17
+ Mirrored the same host rewrite from PR #900 into `FetchQueryExecutor.buildUrl()` (`src/infrastructure/sanity/executors/FetchQueryExecutor.ts`):
18
+
19
+ ```ts
20
+ return `https://sanity.musora.com/${config.projectId}/${api}/v${config.version}/${config.dataset}?perspective=${perspective}`
21
+ ```
22
+
23
+ No other files needed to change — `DefaultConfigProvider`, `SanityConfig`, and `SanityClient` itself are all host-agnostic and pass through unaffected.
24
+
25
+ ## Alternatives Considered
26
+
27
+ No alternatives considered — this is a direct parity fix to bring `SanityClient` in line with the URL rewrite already proven in `fetchSanity()`.
28
+
29
+ ## Process Notes
30
+
31
+ Updated `test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts` — 4 assertions were hardcoded against the old `*.sanity.io` URL format and needed updating to the new `sanity.musora.com` host/path shape.
32
+
33
+ The diff also shows unrelated prettier-driven reformatting in `src/services/sanity.js` (line wrapping, trailing commas) picked up from a main sync — not part of this change's intent.
34
+
35
+ ## Consequences
36
+
37
+ Both Sanity query paths (`fetchSanity()` and `SanityClient`) now route through the Cloudflare caching proxy, so queries made via `SanityClient` benefit from the same CDN caching as the legacy path.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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.180.0](https://github.com/railroadmedia/musora-content-services/compare/v2.179.0...v2.180.0) (2026-09-04)
6
+
7
+
8
+ ### Features
9
+
10
+ * **TP-1335:** consolidate duplicated user data fetches ([#1039](https://github.com/railroadmedia/musora-content-services/issues/1039)) ([bb2af2c](https://github.com/railroadmedia/musora-content-services/commit/bb2af2cddce8da519a414b24d7b86cc96639faaf))
11
+ * **TP-1354:** daily session / active path get optimisations ([#1041](https://github.com/railroadmedia/musora-content-services/issues/1041)) ([bca652c](https://github.com/railroadmedia/musora-content-services/commit/bca652ccf181b1ba943fe6e9b14b375bf4bebfc9))
12
+
5
13
  ## [2.179.0](https://github.com/railroadmedia/musora-content-services/compare/v2.178.1...v2.179.0) (2026-09-03)
6
14
 
7
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.179.0",
3
+ "version": "2.180.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -18,6 +18,7 @@ export class HttpClient {
18
18
  private headerProvider: HeaderProvider
19
19
  private requestExecutor: RequestExecutor
20
20
  private credentials: RequestCredentials
21
+ private inFlightGetRequests: Map<string, Promise<any>> = new Map()
21
22
 
22
23
  constructor(
23
24
  baseUrl: string = '',
@@ -42,7 +43,20 @@ export class HttpClient {
42
43
  }
43
44
 
44
45
  public async get<T>(url: string, options: _RequestOptions = {}): Promise<T> {
45
- return this.request<T>(url, 'GET', options)
46
+ const key = `GET:${this.resolveUrl(url)}:${options.dataVersion || ''}`
47
+
48
+ const inFlightRequest = this.inFlightGetRequests.get(key)
49
+ if (inFlightRequest) {
50
+ return inFlightRequest
51
+ }
52
+
53
+ const request = this.request<T>(url, 'GET', options).finally(() => {
54
+ this.inFlightGetRequests.delete(key)
55
+ })
56
+
57
+ this.inFlightGetRequests.set(key, request)
58
+
59
+ return request
46
60
  }
47
61
 
48
62
  public async post<T>(url: string, data: any, options: _RequestOptions = {}): Promise<T> {
@@ -46,7 +46,7 @@ export class FetchQueryExecutor implements QueryExecutor {
46
46
  private buildUrl(config: SanityConfig): string {
47
47
  const perspective = config.perspective ?? 'published'
48
48
  const api = config.useCachedAPI ? 'apicdn' : 'api'
49
- return `https://${config.projectId}.${api}.sanity.io/v${config.version}/data/query/${config.dataset}?perspective=${perspective}`
49
+ return `https://sanity.musora.com/${config.projectId}/${api}/v${config.version}/${config.dataset}?perspective=${perspective}`
50
50
  }
51
51
 
52
52
  private buildRequest(
@@ -9,6 +9,7 @@ export let globalConfig = {
9
9
  sanityConfig: {},
10
10
  railcontentConfig: {},
11
11
  sessionConfig: {},
12
+ sessionUser: null,
12
13
  localStorage: null,
13
14
  isMA: false,
14
15
  localTimezoneString: null, // In format: America/Vancouver
@@ -109,3 +110,7 @@ export function initializeService(config) {
109
110
  export function initializeEnvVar(config) {
110
111
  globalConfig.appEnv = config.appEnv
111
112
  }
113
+
114
+ export function setSessionUserData(userData) {
115
+ globalConfig.sessionUser = userData || null
116
+ }
@@ -30,12 +30,38 @@ const excludeFromGeneratedIndex = [
30
30
  'onLearningPathCompletedActions',
31
31
  'mapContentsThatWereLastProgressedFromMethod',
32
32
  'mapLearningPathParentsTo',
33
+ 'resetLearningPathCachesForTests',
33
34
  ]
34
35
 
35
36
  const BASE_PATH: string = `/api/content-org`
36
37
  const LEARNING_PATHS_PATH = `${BASE_PATH}/v1/user/learning-paths`
37
- let dailySessionPromise: Promise<DailySessionResponse | ''> | null = null
38
- let activePathPromise: Promise<ActiveLearningPathResponse | ''> | null = null
38
+ const dailySessionPromises = new Map<string, Promise<DailySessionResponse | ''>>()
39
+ const activePathPromises = new Map<string, Promise<ActiveLearningPathResponse | ''>>()
40
+
41
+ function clearLearningPathCaches(): void {
42
+ dailySessionPromises.clear()
43
+ activePathPromises.clear()
44
+ }
45
+
46
+ export function resetLearningPathCachesForTests(): void {
47
+ clearLearningPathCaches()
48
+ }
49
+
50
+ function rememberValue<T>(cache: Map<string, Promise<T>>, key: string, value: T | null): void {
51
+ if (value === null) {
52
+ cache.delete(key)
53
+ } else {
54
+ cache.set(key, Promise.resolve(value))
55
+ }
56
+ }
57
+
58
+ function activePathKey(brand: string): string {
59
+ return `active-path:${brand}`
60
+ }
61
+
62
+ function dailySessionKey(brand: string, dateWithTimezone: string): string {
63
+ return `daily-session:${brand}:${dateWithTimezone}`
64
+ }
39
65
 
40
66
  interface ActiveLearningPathResponse {
41
67
  user_id: number
@@ -67,38 +93,25 @@ interface CollectionObject {
67
93
  * If the daily session doesn't exist, it will be created.
68
94
  * @param brand
69
95
  * @param userDate - local datetime. must have date and time - format 2025-10-31T13:45:00
70
- * @param forceRefresh - force cache refresh
71
96
  */
72
- export async function getDailySession(
73
- brand: string,
74
- userDate: Date,
75
- forceRefresh: boolean = false,
76
- ) {
77
- if (dailySessionPromise && !forceRefresh) {
78
- return dailySessionPromise
79
- }
80
-
81
- dailySessionPromise = (async () => {
82
- const dateWithTimezone = formatLocalDateTime(userDate)
83
- const url = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
97
+ export async function getDailySession(brand: string, userDate: Date) {
98
+ const dateWithTimezone = formatLocalDateTime(userDate)
99
+ const key = dailySessionKey(brand, dateWithTimezone)
84
100
 
85
- const response = await GET(url, {
86
- cache: forceRefresh ? 'reload' : 'default',
87
- }) as DailySessionResponse | ''
101
+ try {
102
+ return await dataPromiseGET(dailySessionPromises, key, async () => {
103
+ const url = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
88
104
 
89
- if (!response) {
90
- return await updateDailySession(brand, userDate, false)
91
- }
92
- return response as DailySessionResponse
93
- })()
105
+ const response = await GET(url) as DailySessionResponse | ''
94
106
 
95
- try {
96
- return await dailySessionPromise
107
+ if (!response) {
108
+ return await updateDailySession(brand, userDate, false)
109
+ }
110
+ return response as DailySessionResponse
111
+ })
97
112
  } catch (error) {
98
113
  console.error('Error fetching daily session:', (error as any).message)
99
114
  return null
100
- } finally {
101
- dailySessionPromise = null
102
115
  }
103
116
  }
104
117
 
@@ -114,6 +127,7 @@ export async function updateDailySession(
114
127
  keepFirstLearningPath: boolean = false,
115
128
  ) {
116
129
  const dateWithTimezone = formatLocalDateTime(userDate)
130
+ const key = dailySessionKey(brand, dateWithTimezone)
117
131
  const url: string = `${LEARNING_PATHS_PATH}/daily-session/create`
118
132
  const body = {
119
133
  brand: brand,
@@ -122,14 +136,10 @@ export async function updateDailySession(
122
136
  }
123
137
  try {
124
138
  const response = (await POST(url, body)) as DailySessionResponse | ''
139
+ rememberValue(dailySessionPromises, key, response !== '' ? response : null)
125
140
 
126
- if (response || response === '') { // refresh cached value
127
- const urlGet: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
128
- dataPromiseGET(urlGet, true).then(() => {
129
- dailySessionPromise = null
130
- })
131
-
132
- }
141
+ const urlGet: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
142
+ GET(urlGet, { cache: 'reload' }).catch(() => {})
133
143
 
134
144
  return (response !== '' ? response : null)
135
145
  } catch (error: any) {
@@ -144,15 +154,13 @@ function formatLocalDateTime(date: Date): string {
144
154
  /**
145
155
  * Gets user's active learning path.
146
156
  * @param brand
147
- * @param forceRefresh - force cache refresh
148
157
  */
149
- export async function getActivePath(brand: string, forceRefresh: boolean = false) {
158
+ export async function getActivePath(brand: string) {
150
159
  const url: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
151
160
 
152
- const response = await dataPromiseGET(url, forceRefresh) as ActiveLearningPathResponse
153
- activePathPromise = null
154
-
155
- return response
161
+ return (await dataPromiseGET(activePathPromises, activePathKey(brand), () =>
162
+ GET(url) as Promise<ActiveLearningPathResponse>,
163
+ )) as ActiveLearningPathResponse
156
164
  }
157
165
 
158
166
  /**
@@ -166,36 +174,36 @@ export async function startLearningPath(brand: string, learningPathId: number) {
166
174
 
167
175
  const response = (await POST(url, body)) as ActiveLearningPathResponse
168
176
 
169
- // manual BE call to avoid recursive POST<->GET calls
170
177
  if (response) {
178
+ rememberValue(activePathPromises, activePathKey(brand), response)
179
+ dailySessionPromises.delete(dailySessionKey(brand, formatLocalDateTime(new Date())))
180
+
171
181
  const urlGet: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
172
- dataPromiseGET(urlGet, true).then(() => {
173
- activePathPromise = null
174
- })
182
+ GET(urlGet, { cache: 'reload' }).catch(() => {})
175
183
  }
176
184
 
177
185
  return response
178
186
  }
179
187
 
180
- async function dataPromiseGET(
181
- url: string,
182
- forceRefresh: boolean,
183
- ): Promise<DailySessionResponse | ActiveLearningPathResponse | ''> {
184
- if (url.includes('daily-session')) {
185
- if (!dailySessionPromise || forceRefresh) {
186
- dailySessionPromise = GET(url, {
187
- cache: forceRefresh ? 'reload' : 'default',
188
- }) as Promise<DailySessionResponse>
189
- }
190
- return dailySessionPromise
191
- } else if (url.includes('active-path')) {
192
- if (!activePathPromise || forceRefresh) {
193
- activePathPromise = GET(url, {
194
- cache: forceRefresh ? 'reload' : 'default',
195
- }) as Promise<ActiveLearningPathResponse>
196
- }
197
- return activePathPromise
188
+ function dataPromiseGET<T>(
189
+ cache: Map<string, Promise<T>>,
190
+ key: string,
191
+ fetcher: () => Promise<T>,
192
+ ): Promise<T> {
193
+ if (cache.has(key)) {
194
+ return cache.get(key)!
198
195
  }
196
+
197
+ const promise = fetcher()
198
+ cache.set(key, promise)
199
+ promise
200
+ .then((value) => {
201
+ if (!value && cache.get(key) === promise) cache.delete(key)
202
+ })
203
+ .catch(() => {
204
+ if (cache.get(key) === promise) cache.delete(key)
205
+ })
206
+ return promise
199
207
  }
200
208
 
201
209
  /**
@@ -204,6 +212,8 @@ async function dataPromiseGET(
204
212
  export async function resetAllLearningPaths() {
205
213
  const url: string = `${LEARNING_PATHS_PATH}/reset`
206
214
 
215
+ clearLearningPathCaches()
216
+
207
217
  return await Promise.all([
208
218
  devFetchAllLearningPathsAndIntroVideoIdsForDelete().then(async (all) => {
209
219
  await Promise.all([
@@ -509,7 +519,16 @@ async function methodIntroVideoCompleteActions(brand: string, learningPathId: nu
509
519
  const dateWithTimezone = formatLocalDateTime(userDate)
510
520
  const url: string = `${LEARNING_PATHS_PATH}/method-intro-video-complete-actions`
511
521
  const body = { brand: brand, learningPathId: learningPathId, userDate: dateWithTimezone }
512
- return (await POST(url, body)) as DailySessionResponse
522
+ const response = (await POST(url, body)) as DailySessionResponse
523
+
524
+ rememberValue(activePathPromises, activePathKey(brand), {
525
+ user_id: response.user_id,
526
+ brand: response.brand,
527
+ active_learning_path_id: response.active_learning_path_id,
528
+ })
529
+ rememberValue(dailySessionPromises, dailySessionKey(brand, dateWithTimezone), response)
530
+
531
+ return response
513
532
  }
514
533
 
515
534
  interface completeLearningPathIntroVideo {
@@ -557,6 +576,7 @@ export async function completeLearningPathIntroVideo(
557
576
  } else {
558
577
  response.lesson_import_response = await contentStatusCompletedMany(lessonsToImport, collection)
559
578
 
579
+ activePathPromises.delete(activePathKey(brand))
560
580
  const activePath = await getActivePath(brand)
561
581
  if (activePath.active_learning_path_id === learningPathId && !lateMethodSetup) { // don't update dailies if they were just set by completeMethodIntroVideoCompleteActions.
562
582
  response.update_dailies_response = await updateDailySession(brand, new Date(), true)
@@ -46,7 +46,10 @@ import { globalConfig } from './config.js'
46
46
 
47
47
  import { arrayToStringRepresentation, FilterBuilder } from '../filterBuilder.js'
48
48
  import { getPermissionsAdapter } from './permissions/index.ts'
49
- import { lifetimeUpgradeDecorator, NEED_LIFETIME_UPGRADE_FIELD } from '../lib/sanity/decorators/need-lifetime-upgrade.ts'
49
+ import {
50
+ lifetimeUpgradeDecorator,
51
+ NEED_LIFETIME_UPGRADE_FIELD,
52
+ } from '../lib/sanity/decorators/need-lifetime-upgrade.ts'
50
53
  import {
51
54
  getAllCompleted,
52
55
  getAllCompletedByIds,
@@ -906,7 +909,8 @@ export async function fetchAllFilterOptions(
906
909
  ? filtersToGroq(filters, excludeFilter)
907
910
  : includedFieldsFilter
908
911
  const statusFilter = ' && status == "published"'
909
- const includeStatusFilter = !hasAllContentAccess && !['instructor', 'artist', 'genre'].includes(contentType)
912
+ const includeStatusFilter =
913
+ !hasAllContentAccess && !['instructor', 'artist', 'genre'].includes(contentType)
910
914
 
911
915
  return coachId
912
916
  ? `brand == '${brand}' && status == "published" && references(*[_type=='instructor' && railcontent_id == ${coachId}]._id) ${filterWithoutOption || ''} ${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}`
@@ -1197,8 +1201,12 @@ export async function fetchLiveEvent(brand, forcedContentId = null) {
1197
1201
 
1198
1202
  const now = new Date()
1199
1203
 
1200
- const startOfYesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1)).toISOString()
1201
- const endOfTomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2)).toISOString()
1204
+ const startOfYesterday = new Date(
1205
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1)
1206
+ ).toISOString()
1207
+ const endOfTomorrow = new Date(
1208
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2)
1209
+ ).toISOString()
1202
1210
 
1203
1211
  const liveEventFields = getLiveFields().concat(
1204
1212
  `'event_coach_calendar_id': coalesce(calendar_id, '${defaultCalendarID}')`
@@ -1226,10 +1234,12 @@ export async function fetchLiveEvent(brand, forcedContentId = null) {
1226
1234
  const windowStart = new Date(clientNow.getTime() - LIVE_EXTRA_MINUTES * 60000).toISOString()
1227
1235
  const windowEnd = new Date(clientNow.getTime() + LIVE_EXTRA_MINUTES * 60000).toISOString()
1228
1236
 
1229
- return events?.find(event =>
1230
- event.live_event_end_time >= windowStart &&
1231
- event.live_event_start_time <= windowEnd
1232
- ) ?? null
1237
+ return (
1238
+ events?.find(
1239
+ (event) =>
1240
+ event.live_event_end_time >= windowStart && event.live_event_start_time <= windowEnd
1241
+ ) ?? null
1242
+ )
1233
1243
  }
1234
1244
 
1235
1245
  /**
@@ -1571,6 +1581,7 @@ export async function fetchCommentModContentData(ids) {
1571
1581
  }
1572
1582
 
1573
1583
  /**
1584
+ * @deprecated Use SanityClient.ts or groq query runner instead. This function is a legacy wrapper for fetching data from the Sanity API using GROQ queries. It handles query construction, execution, and post-processing of results.
1574
1585
  *
1575
1586
  * @param {string} query - The GROQ query to execute against the Sanity API.
1576
1587
  * @param {boolean} isList - Whether to return an array or a single result.
@@ -1586,7 +1597,6 @@ export async function fetchCommentModContentData(ids) {
1586
1597
  * .then(data => console.log(data))
1587
1598
  * .catch(error => console.error(error));
1588
1599
  */
1589
-
1590
1600
  export async function fetchSanity(
1591
1601
  query,
1592
1602
  isList,
@@ -1640,7 +1650,9 @@ export async function fetchSanity(
1640
1650
  return null
1641
1651
  }
1642
1652
  results = processNeedAccess ? await needsAccessDecorator(results, userPermissions) : results
1643
- results = processNeedAccess ? needsLifetimeUpgradeDecorator(results, userPermissions) : results
1653
+ results = processNeedAccess
1654
+ ? needsLifetimeUpgradeDecorator(results, userPermissions)
1655
+ : results
1644
1656
  results = processPageType ? pageTypeDecorator(results) : results
1645
1657
  return customPostProcess ? customPostProcess(results) : results
1646
1658
  } else {
@@ -2124,7 +2136,7 @@ export async function fetchTabData(
2124
2136
  availableContentStatuses: hasAllContentAccess
2125
2137
  ? CONTENT_STATUSES.ADMIN_ALL
2126
2138
  : CONTENT_STATUSES.PUBLISHED_ONLY,
2127
- pullFutureContent: hasAllContentAccess
2139
+ pullFutureContent: hasAllContentAccess,
2128
2140
  }).buildFilter()
2129
2141
  query = buildEntityAndTotalQuery(filterWithRestrictions, entityFieldsString, {
2130
2142
  sortOrder: sortOrder,
@@ -2242,11 +2254,13 @@ export async function fetchScheduledAndNewReleases(
2242
2254
 
2243
2255
  const reordered = reorderScheduledAndNewReleases(r, limit)
2244
2256
  const computedNow = new Date()
2245
- return reordered.map(item => ({
2257
+ return reordered.map((item) => ({
2246
2258
  ...item,
2247
- isLive: item.live_event_start_time && item.live_event_end_time
2248
- ? new Date(item.live_event_start_time) <= computedNow && new Date(item.live_event_end_time) >= computedNow
2249
- : false,
2259
+ isLive:
2260
+ item.live_event_start_time && item.live_event_end_time
2261
+ ? new Date(item.live_event_start_time) <= computedNow &&
2262
+ new Date(item.live_event_end_time) >= computedNow
2263
+ : false,
2250
2264
  }))
2251
2265
  }
2252
2266
 
@@ -1,4 +1,5 @@
1
1
  import { streakCalculator } from './user/streakCalculator'
2
+ import { setSessionUserData } from './config.js'
2
3
 
3
4
  /**
4
5
  * Clears all client-side cached state in musora-content-services.
@@ -7,4 +8,5 @@ import { streakCalculator } from './user/streakCalculator'
7
8
  */
8
9
  export function clearState(): void {
9
10
  streakCalculator.invalidate()
11
+ setSessionUserData(null)
10
12
  }
@@ -76,8 +76,14 @@ export async function deletePicture(pictureUrl) {
76
76
  * @param {number} [userId=globalConfig.sessionConfig.userId]
77
77
  * @returns {Promise<User|null>}
78
78
  */
79
- export async function getUserData(userId = globalConfig.sessionConfig.userId) {
80
- const apiUrl = `${baseUrl}/v1/users/${userId}`
79
+ export async function getUserData(userId) {
80
+ if (!userId && globalConfig.sessionUser) {
81
+ return globalConfig.sessionUser
82
+ }
83
+
84
+ let userIdToFetch = userId || globalConfig.sessionConfig.userId
85
+
86
+ const apiUrl = `${baseUrl}/v1/users/${userIdToFetch}`
81
87
  return await GET(apiUrl)
82
88
  }
83
89
 
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.179.0'
1
+ export const MCS_VERSION = '2.180.0'