musora-content-services 2.93.0 → 2.93.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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(rg:*)",
5
+ "Bash(npm run lint:*)"
6
+ ],
7
+ "deny": []
8
+ }
9
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
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.93.2](https://github.com/railroadmedia/musora-content-services/compare/v2.93.1...v2.93.2) (2025-12-02)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * progress fixes and features with watermelon ([#607](https://github.com/railroadmedia/musora-content-services/issues/607)) ([005403c](https://github.com/railroadmedia/musora-content-services/commit/005403c9bbaeebe8c5ecb5315754de4448bcbf43))
11
+
12
+ ### [2.93.1](https://github.com/railroadmedia/musora-content-services/compare/v2.93.0...v2.93.1) (2025-12-02)
13
+
5
14
  ## [2.93.0](https://github.com/railroadmedia/musora-content-services/compare/v2.92.7...v2.93.0) (2025-12-02)
6
15
 
7
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.93.0",
3
+ "version": "2.93.2",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  contentStatusCompleted,
92
92
  contentStatusReset,
93
93
  contentStatusStarted,
94
+ contentsStatusCompleted,
94
95
  getAllCompleted,
95
96
  getAllCompletedByIds,
96
97
  getAllStarted,
@@ -250,6 +251,7 @@ import {
250
251
  fetchContentRows,
251
252
  fetchFoundation,
252
253
  fetchHierarchy,
254
+ fetchLearningPathHierarchy,
253
255
  fetchLeaving,
254
256
  fetchLessonContent,
255
257
  fetchLessonsFeaturingThisContent,
@@ -428,6 +430,7 @@ declare module 'musora-content-services' {
428
430
  contentStatusCompleted,
429
431
  contentStatusReset,
430
432
  contentStatusStarted,
433
+ contentsStatusCompleted,
431
434
  convertToTimeZone,
432
435
  createComment,
433
436
  createForumCategory,
@@ -494,6 +497,7 @@ declare module 'musora-content-services' {
494
497
  fetchInterests,
495
498
  fetchLastInteractedChild,
496
499
  fetchLatestThreads,
500
+ fetchLearningPathHierarchy,
497
501
  fetchLearningPathLessons,
498
502
  fetchLearningPathProgressCheckLessons,
499
503
  fetchLeaving,
package/src/index.js CHANGED
@@ -95,6 +95,7 @@ import {
95
95
  contentStatusCompleted,
96
96
  contentStatusReset,
97
97
  contentStatusStarted,
98
+ contentsStatusCompleted,
98
99
  getAllCompleted,
99
100
  getAllCompletedByIds,
100
101
  getAllStarted,
@@ -254,6 +255,7 @@ import {
254
255
  fetchContentRows,
255
256
  fetchFoundation,
256
257
  fetchHierarchy,
258
+ fetchLearningPathHierarchy,
257
259
  fetchLeaving,
258
260
  fetchLessonContent,
259
261
  fetchLessonsFeaturingThisContent,
@@ -427,6 +429,7 @@ export {
427
429
  contentStatusCompleted,
428
430
  contentStatusReset,
429
431
  contentStatusStarted,
432
+ contentsStatusCompleted,
430
433
  convertToTimeZone,
431
434
  createComment,
432
435
  createForumCategory,
@@ -493,6 +496,7 @@ export {
493
496
  fetchInterests,
494
497
  fetchLastInteractedChild,
495
498
  fetchLatestThreads,
499
+ fetchLearningPathHierarchy,
496
500
  fetchLearningPathLessons,
497
501
  fetchLearningPathProgressCheckLessons,
498
502
  fetchLeaving,
@@ -7,11 +7,14 @@ import { fetchByRailContentId, fetchMethodV2Structure } from '../sanity.js'
7
7
  import { addContextToContent } from '../contentAggregator.js'
8
8
  import {
9
9
  contentStatusCompleted,
10
+ contentsStatusCompleted,
10
11
  contentStatusReset,
11
12
  getAllCompletedByIds,
12
13
  getProgressState,
13
14
  } from '../contentProgress.js'
14
- import { STATE } from '../sync/models/ContentProgress'
15
+ import { COLLECTION_TYPE, STATE } from "../sync/models/ContentProgress";
16
+ import { SyncWriteDTO } from "../sync";
17
+ import { ContentProgress } from "../sync/models";
15
18
 
16
19
  const BASE_PATH: string = `/api/content-org`
17
20
  const LEARNING_PATHS_PATH = `${BASE_PATH}/v1/user/learning-paths`
@@ -22,6 +25,20 @@ interface ActiveLearningPathResponse {
22
25
  active_learning_path_id: number,
23
26
  }
24
27
 
28
+ interface DailySessionResponse {
29
+ user_id: number,
30
+ brand: string,
31
+ user_date: string
32
+ daily_session: DailySession[],
33
+ active_learning_path_id: number,
34
+ active_learning_path_created_at: string,
35
+ }
36
+
37
+ interface DailySession {
38
+ content_ids: number[],
39
+ learning_path_id: number,
40
+ }
41
+
25
42
  /**
26
43
  * Gets today's daily session for the user.
27
44
  * @param brand
@@ -30,7 +47,7 @@ interface ActiveLearningPathResponse {
30
47
  export async function getDailySession(brand: string, userDate: Date) {
31
48
  const stringDate = userDate.toISOString().split('T')[0]
32
49
  const url: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${stringDate}`
33
- return await fetchHandler(url, 'GET', null, null)
50
+ return await fetchHandler(url, 'GET', null, null) as DailySessionResponse
34
51
  }
35
52
 
36
53
  /**
@@ -47,7 +64,7 @@ export async function updateDailySession(
47
64
  const stringDate = userDate.toISOString().split('T')[0]
48
65
  const url: string = `${LEARNING_PATHS_PATH}/daily-session/create`
49
66
  const body = { brand: brand, userDate: stringDate, keepFirstLearningPath: keepFirstLearningPath }
50
- return await fetchHandler(url, 'POST', null, body)
67
+ return await fetchHandler(url, 'POST', null, body) as DailySessionResponse
51
68
  }
52
69
 
53
70
  /**
@@ -56,7 +73,7 @@ export async function updateDailySession(
56
73
  */
57
74
  export async function getActivePath(brand: string) {
58
75
  const url: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
59
- return await fetchHandler(url, 'GET', null, null)
76
+ return await fetchHandler(url, 'GET', null, null) as ActiveLearningPathResponse
60
77
  }
61
78
 
62
79
  /**
@@ -67,7 +84,7 @@ export async function getActivePath(brand: string) {
67
84
  export async function startLearningPath(brand: string, learningPathId: number) {
68
85
  const url: string = `${LEARNING_PATHS_PATH}/active-path/set`
69
86
  const body = { brand: brand, learning_path_id: learningPathId }
70
- return await fetchHandler(url, 'POST', null, body)
87
+ return await fetchHandler(url, 'POST', null, body) as ActiveLearningPathResponse
71
88
  }
72
89
 
73
90
  /**
@@ -87,9 +104,9 @@ export async function getEnrichedLearningPath(learningPathId) {
87
104
  const response = (await addContextToContent(
88
105
  fetchByRailContentId,
89
106
  learningPathId,
90
- 'learning-path-v2',
107
+ COLLECTION_TYPE.LEARNING_PATH,
91
108
  {
92
- collection: { id: learningPathId, type: 'learning-path-v2' },
109
+ collection: { id: learningPathId, type: COLLECTION_TYPE.LEARNING_PATH },
93
110
  dataField: 'children',
94
111
  dataField_includeParent: true,
95
112
  addProgressStatus: true,
@@ -241,7 +258,7 @@ export async function fetchLearningPathProgressCheckLessons(contentIds: number[]
241
258
  }
242
259
 
243
260
  interface completeMethodIntroVideo {
244
- intro_video_response: Object | null,
261
+ intro_video_response: SyncWriteDTO<ContentProgress, any> | null,
245
262
  active_path_response: ActiveLearningPathResponse
246
263
  }
247
264
  /**
@@ -267,9 +284,9 @@ export async function completeMethodIntroVideo(introVideoId: number, brand: stri
267
284
  }
268
285
 
269
286
  interface completeLearningPathIntroVideo {
270
- intro_video_response: Object | null,
271
- learning_path_reset_response: void | null,
272
- lesson_import_response: Object | null
287
+ intro_video_response: SyncWriteDTO<ContentProgress, any> | null,
288
+ learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null,
289
+ lesson_import_response: SyncWriteDTO<ContentProgress, any> | null
273
290
  }
274
291
  /**
275
292
  * Handles completion of learning path intro video and other related actions.
@@ -286,24 +303,20 @@ export async function completeLearningPathIntroVideo(introVideoId: number, learn
286
303
 
287
304
  response.intro_video_response = await completeIfNotCompleted(introVideoId)
288
305
 
289
- const collection = { id: learningPathId, type: 'learning-path-v2' }
306
+ const collection = { id: learningPathId, type: COLLECTION_TYPE.LEARNING_PATH }
290
307
 
291
308
  if (!lessonsToImport) {
292
309
  response.learning_path_reset_response = await contentStatusReset(learningPathId, collection)
293
310
 
294
311
  } else {
295
- response.lesson_import_response = {}
296
- for (const contentId of lessonsToImport) {
297
- // todo: create bulk complete endpoint with bubbling. and set up watermelon method bubbling
298
- response.lesson_import_response[contentId] = await contentStatusCompleted(contentId, collection)
299
- }
312
+ response.lesson_import_response = await contentsStatusCompleted(lessonsToImport, collection)
300
313
  }
301
314
 
302
315
  return response
303
316
  }
304
317
 
305
318
 
306
- async function completeIfNotCompleted(contentId: number): Promise<Object | null> {
319
+ async function completeIfNotCompleted(contentId: number): Promise<SyncWriteDTO<ContentProgress, any> | null> {
307
320
  const introVideoStatus = await getProgressState(contentId)
308
321
 
309
322
  return introVideoStatus !== 'completed' ? await contentStatusCompleted(contentId) : null
@@ -1,6 +1,6 @@
1
- import { fetchHierarchy } from './sanity.js'
1
+ import { fetchHierarchy, fetchLearningPathHierarchy } from './sanity.js'
2
2
  import { db } from './sync'
3
- import { STATE } from './sync/models/ContentProgress'
3
+ import {COLLECTION_TYPE, STATE} from './sync/models/ContentProgress'
4
4
  import { trackUserPractice, findIncompleteLesson } from './userActivity'
5
5
  import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
6
6
 
@@ -66,7 +66,7 @@ export async function getNavigateTo(data, collection = null) {
66
66
  let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
67
67
  navigateToData[content.id] = buildNavigateTo(children.get(incompleteChild), null, collection)
68
68
  }
69
- } else if (['song-tutorial', 'guided-course', 'learning-path-v2'].includes(content.type)) { // send to first incomplete
69
+ } else if (['song-tutorial', 'guided-course', COLLECTION_TYPE.LEARNING_PATH].includes(content.type)) { // send to first incomplete
70
70
  let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
71
71
  navigateToData[content.id] = buildNavigateTo(children.get(incompleteChild), null, collection)
72
72
  } else if (twoDepthContentTypes.includes(content.type)) { // send to navigateTo child of last interacted child
@@ -268,6 +268,11 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
268
268
  export async function contentStatusCompleted(contentId, collection = null) {
269
269
  return setStartedOrCompletedStatus(contentId, collection, true)
270
270
  }
271
+
272
+ export async function contentsStatusCompleted(contentIds, collection = null) {
273
+ return setStartedOrCompletedStatuses(contentIds, collection, true)
274
+ }
275
+
271
276
  export async function contentStatusStarted(contentId, collection = null) {
272
277
  return setStartedOrCompletedStatus(contentId, collection, false)
273
278
  }
@@ -276,12 +281,12 @@ export async function contentStatusReset(contentId, collection = null) {
276
281
  }
277
282
 
278
283
  async function saveContentProgress(contentId, collection, progress, currentSeconds) {
279
- const response = await db.contentProgress.recordProgressRemotely(contentId, collection, progress, currentSeconds)
284
+ const response = await db.contentProgress.recordProgress(contentId, collection, progress, currentSeconds)
280
285
 
281
286
  // note - previous implementation explicitly did not trickle progress to children here
282
287
  // (only to siblings/parents via le bubbles)
283
288
 
284
- const bubbledProgresses = bubbleProgress(await fetchHierarchy(contentId), contentId, collection)
289
+ const bubbledProgresses = bubbleProgress(await getHierarchy(contentId, collection), contentId, collection)
285
290
  await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
286
291
 
287
292
  return response
@@ -292,23 +297,55 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
292
297
  // we explicitly pessimistically await a remote push here
293
298
  // because awards may be generated (on server) on completion
294
299
  // which we would want to toast the user about *in band*
295
- const response = await db.contentProgress.recordProgressRemotely(contentId, collection, progress)
300
+ const response = await db.contentProgress.recordProgress(contentId, collection, progress)
301
+
302
+ const hierarchy = await getHierarchy(contentId, collection)
303
+
304
+ await Promise.all([
305
+ db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, progress), collection),
306
+ bubbleProgress(hierarchy, contentId, collection).then(bubbledProgresses => db.contentProgress.recordProgressesTentative(bubbledProgresses, collection))
307
+ ])
296
308
 
297
- if (response.pushStatus === 'success') {
298
- const hierarchy = await fetchHierarchy(contentId)
309
+ return response
310
+ }
299
311
 
300
- await Promise.all([
301
- db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, progress), collection),
302
- bubbleProgress(hierarchy, contentId, collection).then(bubbledProgresses => db.contentProgress.recordProgressesTentative(bubbledProgresses, collection))
303
- ])
312
+ async function getHierarchy(contentId, collection) {
313
+ if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
314
+ return await fetchLearningPathHierarchy(contentId, collection)
315
+ } else {
316
+ return await fetchHierarchy(contentId)
304
317
  }
318
+ }
319
+
320
+ async function setStartedOrCompletedStatuses(contentIds, collection, isCompleted) {
321
+ const progress = isCompleted ? 100 : 0
322
+ // we explicitly pessimistically await a remote push here
323
+ // because awards may be generated (on server) on completion
324
+ // which we would want to toast the user about *in band*
325
+ const response = await db.contentProgress.recordProgresses(contentIds, collection, progress)
326
+
327
+ // we assume this is used only for contents within the same hierarchy
328
+ const hierarchy = await getHierarchy(contentIds[0], collection)
329
+
330
+ let ids = {}
331
+ for (const contentId of contentIds) {
332
+ ids = {
333
+ ...ids,
334
+ ...trickleProgress(hierarchy, contentId, collection, progress),
335
+ ...await bubbleProgress(hierarchy, contentId, collection)
336
+ }
337
+ }
338
+
339
+ await Promise.all([
340
+ db.contentProgress.recordProgressesTentative(ids, collection),
341
+ ]);
305
342
 
306
343
  return response
307
344
  }
308
345
 
309
346
  async function resetStatus(contentId, collection = null) {
310
347
  const response = await db.contentProgress.eraseProgress(contentId, collection)
311
- const hierarchy = await fetchHierarchy(contentId)
348
+ const hierarchy = await getHierarchy(contentId, collection)
312
349
 
313
350
  await Promise.all([
314
351
  db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, 0), collection),
@@ -6,6 +6,7 @@ import { getActivePath, fetchLearningPathLessons } from '../content-org/learning
6
6
  import { getToday } from '../dateUtils.js'
7
7
  import { fetchMethodV2IntroVideo } from '../sanity'
8
8
  import { getProgressState } from '../contentProgress'
9
+ import {COLLECTION_TYPE} from "../sync/models/ContentProgress.js";
9
10
 
10
11
  export async function getMethodCard(brand) {
11
12
  const introVideo = await fetchMethodV2IntroVideo(brand)
@@ -82,7 +83,7 @@ export async function getMethodCard(brand) {
82
83
 
83
84
  return {
84
85
  id: 1,
85
- type: 'learning-path-v2',
86
+ type: COLLECTION_TYPE.LEARNING_PATH,
86
87
  progressType: 'method',
87
88
  header: 'Method',
88
89
  body: learningPath,
@@ -1523,6 +1523,25 @@ export async function fetchTopLevelParentId(railcontentId) {
1523
1523
  return response['railcontent_id']
1524
1524
  }
1525
1525
 
1526
+ export async function fetchLearningPathHierarchy(railcontentId, collection) {
1527
+ if (!collection) {
1528
+ return null
1529
+ }
1530
+
1531
+ const topLevelId = collection.id
1532
+
1533
+ let response = await fetchByRailContentId(topLevelId, collection.type)
1534
+ if (!response) return null
1535
+
1536
+ let data = {
1537
+ topLevelId: topLevelId,
1538
+ parents: {},
1539
+ children: {},
1540
+ }
1541
+ populateHierarchyLookups(response, data, null)
1542
+ return data
1543
+ }
1544
+
1526
1545
  export async function fetchHierarchy(railcontentId) {
1527
1546
  let topLevelId = await fetchTopLevelParentId(railcontentId)
1528
1547
  const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
@@ -1557,12 +1576,14 @@ export async function fetchHierarchy(railcontentId) {
1557
1576
  }
1558
1577
 
1559
1578
  function populateHierarchyLookups(currentLevel, data, parentId) {
1560
- let contentId = currentLevel['railcontent_id']
1579
+ const railcontentIdField = currentLevel.railcontent_id ? "railcontent_id" : "id";
1580
+
1581
+ let contentId = currentLevel[railcontentIdField]
1561
1582
  let children = currentLevel['children']
1562
1583
 
1563
1584
  data.parents[contentId] = parentId
1564
1585
  if (children) {
1565
- data.children[contentId] = children.map((child) => child['railcontent_id'])
1586
+ data.children[contentId] = children.map((child) => child[railcontentIdField])
1566
1587
  for (let i = 0; i < children.length; i++) {
1567
1588
  populateHierarchyLookups(children[i], data, contentId)
1568
1589
  }
@@ -1572,7 +1593,7 @@ function populateHierarchyLookups(currentLevel, data, parentId) {
1572
1593
 
1573
1594
  let assignments = currentLevel['assignments']
1574
1595
  if (assignments) {
1575
- let assignmentIds = assignments.map((assignment) => assignment['railcontent_id'])
1596
+ let assignmentIds = assignments.map((assignment) => assignment[railcontentIdField])
1576
1597
  data.children[contentId] = (data.children[contentId] ?? []).concat(assignmentIds)
1577
1598
  assignmentIds.forEach((assignmentId) => {
1578
1599
  data.parents[assignmentId] = contentId
@@ -29,7 +29,7 @@ export function inBoundary<T, TContext extends Record<string, any>>(fn: (context
29
29
  if (result instanceof Promise) {
30
30
  return result.catch((err: unknown) => {
31
31
  const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
32
- SyncTelemetry.getInstance().capture(wrapped)
32
+ SyncTelemetry.getInstance()?.capture(wrapped)
33
33
 
34
34
  throw wrapped;
35
35
  });
@@ -38,7 +38,7 @@ export function inBoundary<T, TContext extends Record<string, any>>(fn: (context
38
38
  return result;
39
39
  } catch (err: unknown) {
40
40
  const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
41
- SyncTelemetry.getInstance().capture(wrapped);
41
+ SyncTelemetry.getInstance()?.capture(wrapped);
42
42
 
43
43
  throw wrapped;
44
44
  }
@@ -23,19 +23,19 @@ interface RawPushResponse {
23
23
  }
24
24
 
25
25
  export type SyncResponse = SyncPushResponse | SyncPullResponse
26
+ export type SyncPushResponse = SyncPushSuccessResponse | SyncPushFetchFailureResponse | SyncPushFailureResponse
26
27
 
27
- export type SyncPushResponse = SyncPushSuccessResponse | SyncPushFailureResponse
28
-
29
- type SyncPushSuccessResponse = SyncPushResponseBase & {
28
+ type SyncPushSuccessResponse = SyncResponseBase & {
30
29
  ok: true
31
30
  results: SyncStorePushResult[]
32
31
  }
33
- type SyncPushFailureResponse = SyncPushResponseBase & {
32
+ type SyncPushFetchFailureResponse = SyncResponseBase & {
34
33
  ok: false,
35
- originalError: Error
34
+ isRetryable: boolean
36
35
  }
37
- interface SyncPushResponseBase extends SyncResponseBase {
38
-
36
+ type SyncPushFailureResponse = SyncResponseBase & {
37
+ ok: false,
38
+ originalError: Error
39
39
  }
40
40
 
41
41
  type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
@@ -61,20 +61,21 @@ interface SyncStorePushResultBase {
61
61
  type: 'success' | 'failure'
62
62
  }
63
63
 
64
- export type SyncPullResponse = SyncPullSuccessResponse | SyncPullFailureResponse
64
+ export type SyncPullResponse = SyncPullSuccessResponse | SyncPullFailureResponse | SyncPullFetchFailureResponse
65
65
 
66
- type SyncPullSuccessResponse = SyncPullResponseBase & {
66
+ type SyncPullSuccessResponse = SyncResponseBase & {
67
67
  ok: true
68
68
  entries: SyncEntry[]
69
69
  token: SyncToken
70
70
  previousToken: SyncToken | null
71
71
  }
72
- type SyncPullFailureResponse = SyncPullResponseBase & {
72
+ type SyncPullFailureResponse = SyncResponseBase & {
73
73
  ok: false,
74
- originalError: Error
74
+ isRetryable: boolean
75
75
  }
76
- interface SyncPullResponseBase extends SyncResponseBase {
77
-
76
+ type SyncPullFetchFailureResponse = SyncResponseBase & {
77
+ ok: false,
78
+ originalError: Error
78
79
  }
79
80
  export interface SyncResponseBase {
80
81
  ok: boolean
@@ -141,11 +142,18 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
141
142
 
142
143
  let response: Response | null = null
143
144
  try {
144
- response = await performFetch(request)
145
+ response = await fetch(request)
145
146
  } catch (e) {
146
147
  return {
147
148
  ok: false,
148
- originalError: e
149
+ originalError: e as Error
150
+ }
151
+ }
152
+
153
+ if (response.ok === false) {
154
+ return {
155
+ ok: false,
156
+ isRetryable: (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
149
157
  }
150
158
  }
151
159
 
@@ -180,11 +188,18 @@ export function handlePush(callback: (session: BaseSessionProvider) => Request)
180
188
 
181
189
  let response: Response | null = null
182
190
  try {
183
- response = await performFetch(request)
191
+ response = await fetch(request)
184
192
  } catch (e) {
185
193
  return {
186
194
  ok: false,
187
- originalError: e
195
+ originalError: e as Error
196
+ }
197
+ }
198
+
199
+ if (response.ok === false) {
200
+ return {
201
+ ok: false,
202
+ isRetryable: (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
188
203
  }
189
204
  }
190
205
 
@@ -198,17 +213,6 @@ export function handlePush(callback: (session: BaseSessionProvider) => Request)
198
213
  }
199
214
  }
200
215
 
201
- async function performFetch(request: Request) {
202
- const response = await fetch(request)
203
- const isRetryable = (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
204
-
205
- if (isRetryable) {
206
- throw new Error(`Server returned ${response.status}`)
207
- }
208
-
209
- return response
210
- }
211
-
212
216
  function serializePullUrlQuery(url: string, fetchToken: SyncToken | null) {
213
217
  const queryString = url.replace(/^[^?]*\??/, '');
214
218
  const searchParams = new URLSearchParams(queryString);
@@ -14,6 +14,7 @@ import { inBoundary } from './errors/boundary'
14
14
  import createStoresFromConfig from './store-configs'
15
15
 
16
16
  export default class SyncManager {
17
+ private static counter = 0
17
18
  private static instance: SyncManager | null = null
18
19
 
19
20
  public static assignAndSetupInstance(instance: SyncManager) {
@@ -23,8 +24,8 @@ export default class SyncManager {
23
24
  SyncManager.instance = instance
24
25
  const teardown = instance.setup()
25
26
  return async () => {
26
- await teardown()
27
27
  SyncManager.instance = null
28
+ await teardown()
28
29
  }
29
30
  }
30
31
 
@@ -35,6 +36,7 @@ export default class SyncManager {
35
36
  return SyncManager.instance
36
37
  }
37
38
 
39
+ private id: string
38
40
  public telemetry: SyncTelemetry
39
41
  private database: Database
40
42
  private context: SyncContext
@@ -45,10 +47,12 @@ export default class SyncManager {
45
47
  private safetyMap: { stores: SyncStore<any>[]; mechanisms: (() => void)[] }[]
46
48
 
47
49
  constructor(context: SyncContext, initDatabase: () => Database) {
50
+ this.id = (SyncManager.counter++).toString()
51
+
48
52
  this.telemetry = SyncTelemetry.getInstance()!
49
53
  this.context = context
50
54
 
51
- this.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase))
55
+ this.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase)) // todo - can cause undefined??
52
56
 
53
57
  this.runScope = new SyncRunScope()
54
58
  this.retry = new SyncRetry(this.context, this.telemetry)
@@ -59,6 +63,13 @@ export default class SyncManager {
59
63
  this.safetyMap = []
60
64
  }
61
65
 
66
+ /**
67
+ * Useful as a cache key (if user logs in and out multiple times, creating multiple managers)
68
+ */
69
+ getId() {
70
+ return this.id
71
+ }
72
+
62
73
  createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
63
74
  return new SyncStore<TModel>(config, this.context, this.database, this.retry, this.runScope, this.telemetry)
64
75
  }
@@ -3,7 +3,7 @@ import { SYNC_TABLES } from '../schema'
3
3
 
4
4
  export enum COLLECTION_TYPE {
5
5
  SKILL_PACK = 'skill-pack',
6
- LEARNING_PATH = 'learning-path',
6
+ LEARNING_PATH = 'learning-path-v2',
7
7
  PLAYLIST = 'playlist',
8
8
  }
9
9
 
@@ -115,10 +115,10 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
115
115
  return await this.queryAll(...clauses)
116
116
  }
117
117
 
118
- recordProgressRemotely(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null, progressPct: number, resumeTime?: number) {
118
+ recordProgress(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null, progressPct: number, resumeTime?: number) {
119
119
  const id = ProgressRepository.generateId(contentId, collection)
120
120
 
121
- return this.upsertOneRemote(id, (r) => {
121
+ return this.upsertOne(id, (r) => {
122
122
  r.content_id = contentId
123
123
  r.collection_type = collection?.type ?? null
124
124
  r.collection_id = collection?.id ?? null
@@ -132,12 +132,16 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
132
132
  })
133
133
  }
134
134
 
135
- recordProgressesTentative(contentProgresses: Map<number, number>, collection: { type: COLLECTION_TYPE; id: number } | null) {
136
- return this.upsertSomeTentative(
135
+ recordProgresses(
136
+ contentIds: number[],
137
+ collection: { type: COLLECTION_TYPE; id: number } | null,
138
+ progressPct: number
139
+ ) {
140
+ return this.upsertSome(
137
141
  Object.fromEntries(
138
- Array.from(contentProgresses, ([contentId, progressPct]) => [
139
- ProgressRepository.generateId(contentId, null),
140
- (r) => {
142
+ contentIds.map((contentId) => [
143
+ ProgressRepository.generateId(contentId, collection),
144
+ (r: ContentProgress) => {
141
145
  r.content_id = contentId
142
146
  r.collection_type = collection?.type ?? null
143
147
  r.collection_id = collection?.id ?? null
@@ -150,6 +154,29 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
150
154
  )
151
155
  }
152
156
 
157
+ recordProgressesTentative(
158
+ contentProgresses: Record<string, number>, // Accept plain object
159
+ collection: { type: COLLECTION_TYPE; id: number } | null
160
+ ) {
161
+ const data = Object.fromEntries(
162
+ Object.entries(contentProgresses).map(([contentId, progressPct]) => {
163
+ const generatedId = ProgressRepository.generateId(Number(contentId), collection)
164
+ console.log('Processing:', { contentId, progressPct, generatedId, collection })
165
+ return [
166
+ generatedId,
167
+ (record: ContentProgress) => {
168
+ record.content_id = Number(contentId)
169
+ record.collection_type = collection?.type ?? null
170
+ record.collection_id = collection?.id ?? null
171
+ record.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
172
+ record.progress_percent = progressPct
173
+ },
174
+ ]
175
+ })
176
+ )
177
+ return this.upsertSomeTentative(data)
178
+ }
179
+
153
180
  eraseProgress(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null) {
154
181
  return this.deleteOne(ProgressRepository.generateId(contentId, collection))
155
182
  }
@@ -14,35 +14,50 @@ import {
14
14
  PracticeDayNote
15
15
  } from "./models"
16
16
 
17
+
17
18
  interface SyncRepositories {
18
- likes: ContentLikesRepository
19
- contentProgress: ContentProgressRepository
20
- practices: PracticesRepository
21
- practiceDayNotes: PracticeDayNotesRepository
19
+ likes: ContentLikesRepository;
20
+ contentProgress: ContentProgressRepository;
21
+ practices: PracticesRepository;
22
+ practiceDayNotes: PracticeDayNotesRepository;
22
23
  }
23
24
 
24
- export default new Proxy({} as SyncRepositories, {
25
- get(target: SyncRepositories, prop: keyof SyncRepositories) {
26
- if (!target[prop]) {
27
- const manager = SyncManager.getInstance()
28
25
 
26
+ // internal cache for repositories, keyed by managerId and property name
27
+ const repoCache: Record<string, Partial<SyncRepositories>> = {};
28
+
29
+ const proxy = new Proxy({} as SyncRepositories, {
30
+ get(_target, prop: keyof SyncRepositories) {
31
+ const manager = SyncManager.getInstance();
32
+ const managerId = manager.getId();
33
+
34
+ if (!repoCache[managerId]) {
35
+ repoCache[managerId] = {};
36
+ }
37
+ const cache = repoCache[managerId];
38
+
39
+ if (!cache[prop]) {
29
40
  switch (prop) {
30
41
  case 'likes':
31
- target[prop] = new ContentLikesRepository(manager.getStore(ContentLike))
32
- break
42
+ cache.likes = new ContentLikesRepository(manager.getStore(ContentLike));
43
+ break;
33
44
  case 'contentProgress':
34
- target[prop] = new ContentProgressRepository(manager.getStore(ContentProgress))
35
- break
45
+ cache.contentProgress = new ContentProgressRepository(manager.getStore(ContentProgress));
46
+ break;
36
47
  case 'practices':
37
- target[prop] = new PracticesRepository(manager.getStore(Practice))
38
- break
48
+ cache.practices = new PracticesRepository(manager.getStore(Practice));
49
+ break;
39
50
  case 'practiceDayNotes':
40
- target[prop] = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote))
41
- break
51
+ cache.practiceDayNotes = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote));
52
+ break;
42
53
  default:
43
- throw new SyncError(`Repository '${prop}' not found`)
54
+ throw new SyncError(`Repository '${String(prop)}' not found`);
44
55
  }
45
56
  }
46
- return target[prop]
57
+ return cache[prop];
47
58
  }
48
- })
59
+ });
60
+
61
+ export default proxy;
62
+
63
+
@@ -56,8 +56,14 @@ export default class SyncRetry {
56
56
  this.resetBackoff()
57
57
  return result
58
58
  } else {
59
- this.scheduleBackoff()
60
- if (attempt >= this.MAX_ATTEMPTS) return result
59
+ const isRetryable = 'isRetryable' in result ? result.isRetryable : false
60
+
61
+ if (isRetryable) {
62
+ this.scheduleBackoff()
63
+ if (attempt >= this.MAX_ATTEMPTS) return result
64
+ } else {
65
+ return result
66
+ }
61
67
  }
62
68
  }
63
69
  }
@@ -424,19 +424,21 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
424
424
  }
425
425
 
426
426
  private async setLastFetchToken(token: SyncToken | null) {
427
- await this.db.write(async () => {
428
- if (token) {
429
- const storedValue = await this.getLastFetchToken()
430
-
431
- // avoids thrashing if we get and compare first before setting
432
- if (storedValue !== token) {
433
- this.telemetry.debug(`[store:${this.model.table}] Setting last fetch token: ${token}`)
434
- return this.db.localStorage.set(this.lastFetchTokenKey, token)
427
+ await this.runScope.abortable(async () => {
428
+ await this.db.write(async () => {
429
+ if (token) {
430
+ const storedValue = await this.getLastFetchToken()
431
+
432
+ // avoids thrashing if we get and compare first before setting
433
+ if (storedValue !== token) {
434
+ this.telemetry.debug(`[store:${this.model.table}] Setting last fetch token: ${token}`)
435
+ return this.db.localStorage.set(this.lastFetchTokenKey, token)
436
+ }
437
+ } else {
438
+ this.telemetry.debug(`[store:${this.model.table}] Removing last fetch token`)
439
+ return this.db.localStorage.remove(this.lastFetchTokenKey)
435
440
  }
436
- } else {
437
- this.telemetry.debug(`[store:${this.model.table}] Removing last fetch token`)
438
- return this.db.localStorage.remove(this.lastFetchTokenKey)
439
- }
441
+ })
440
442
  })
441
443
  }
442
444
 
@@ -75,7 +75,6 @@ export class SyncTelemetry {
75
75
  }
76
76
  } : undefined)
77
77
 
78
-
79
78
  this._ignoreConsole = true
80
79
  this.error(err.message)
81
80
  this._ignoreConsole = false
@@ -4,7 +4,7 @@ import { SyncError } from '../errors'
4
4
  type ReturnsUndefined<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T> | undefined
5
5
 
6
6
  export const syncSentryBeforeSend: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSend']>> = (event, hint) => {
7
- if (event.logger === 'console' && SyncTelemetry.getInstance().shouldIgnoreConsole()) {
7
+ if (event.logger === 'console' && SyncTelemetry.getInstance()?.shouldIgnoreConsole()) {
8
8
  return null
9
9
  }
10
10
 
@@ -40,6 +40,7 @@ import dayjs from 'dayjs'
40
40
  import { addContextToContent } from './contentAggregator.js'
41
41
  import { getMethodCard } from './progress-row/method-card.js'
42
42
  import { db, Q } from './sync'
43
+ import {COLLECTION_TYPE} from "./sync/models/ContentProgress.js";
43
44
 
44
45
  const DATA_KEY_PRACTICES = 'practices'
45
46
 
@@ -1039,7 +1040,7 @@ export async function getProgressRows({ brand = 'drumeo', limit = 8 } = {}) {
1039
1040
  switch (item.type) {
1040
1041
  case 'playlist':
1041
1042
  return processPlaylistItem(item)
1042
- case 'learning-path-v2':
1043
+ case COLLECTION_TYPE.LEARNING_PATH:
1043
1044
  case 'method':
1044
1045
  return item
1045
1046
  default:
@@ -1282,7 +1283,7 @@ function mergeAndSortItems(items, limit) {
1282
1283
 
1283
1284
  export function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1284
1285
  const ids = Object.keys(progressOnItems).map(Number)
1285
- if (contentType === 'guided-course' || contentType === 'learning-path-v2') {
1286
+ if (contentType === 'guided-course' || contentType === COLLECTION_TYPE.LEARNING_PATH) {
1286
1287
  // Return first incomplete lesson
1287
1288
  return ids.find((id) => progressOnItems[id] !== 'completed') || ids.at(0)
1288
1289
  }