musora-content-services 2.95.5 → 2.96.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,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,13 @@
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.96.0](https://github.com/railroadmedia/musora-content-services/compare/v2.95.5...v2.96.0) (2025-12-09)
6
+
7
+
8
+ ### Features
9
+
10
+ * handle learning path complete logic ([#628](https://github.com/railroadmedia/musora-content-services/issues/628)) ([e5266d2](https://github.com/railroadmedia/musora-content-services/commit/e5266d2219597fc295dab97cec7b7ce8ac80a705))
11
+
5
12
  ### [2.95.5](https://github.com/railroadmedia/musora-content-services/compare/v2.95.4...v2.95.5) (2025-12-08)
6
13
 
7
14
  ### [2.95.4](https://github.com/railroadmedia/musora-content-services/compare/v2.95.3...v2.95.4) (2025-12-08)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.95.5",
3
+ "version": "2.96.0",
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
@@ -52,6 +52,7 @@ import {
52
52
  getEnrichedLearningPath,
53
53
  getLearningPathLessonsByIds,
54
54
  mapContentToParent,
55
+ onContentCompletedLearningPathListener,
55
56
  resetAllLearningPaths,
56
57
  startLearningPath,
57
58
  updateDailySession
@@ -193,7 +194,9 @@ import {
193
194
  } from './services/liveTesting.ts';
194
195
 
195
196
  import {
197
+ emitContentCompleted,
196
198
  emitProgressSaved,
199
+ onContentCompleted,
197
200
  onProgressSaved
198
201
  } from './services/progress-events.js';
199
202
 
@@ -423,7 +426,7 @@ import {
423
426
  } from './services/userActivity.js';
424
427
 
425
428
  import {
426
- default as EventsAPI
429
+ default as EventsAPI
427
430
  } from './services/eventsAPI';
428
431
 
429
432
  declare module 'musora-content-services' {
@@ -468,6 +471,7 @@ declare module 'musora-content-services' {
468
471
  deleteUserActivity,
469
472
  duplicatePlaylist,
470
473
  editComment,
474
+ emitContentCompleted,
471
475
  emitProgressSaved,
472
476
  enrollUserInGuidedCourse,
473
477
  extractSanityUrl,
@@ -651,6 +655,8 @@ declare module 'musora-content-services' {
651
655
  markNotificationAsUnread,
652
656
  markThreadAsRead,
653
657
  numberOfActiveUsers,
658
+ onContentCompleted,
659
+ onContentCompletedLearningPathListener,
654
660
  onProgressSaved,
655
661
  openComment,
656
662
  otherStats,
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /*** This file was generated automatically. To recreate, please run `npm run build-index`. ***/
2
2
 
3
3
  import {
4
- default as EventsAPI
4
+ default as EventsAPI
5
5
  } from './services/eventsAPI';
6
6
 
7
7
  import {
@@ -56,6 +56,7 @@ import {
56
56
  getEnrichedLearningPath,
57
57
  getLearningPathLessonsByIds,
58
58
  mapContentToParent,
59
+ onContentCompletedLearningPathListener,
59
60
  resetAllLearningPaths,
60
61
  startLearningPath,
61
62
  updateDailySession
@@ -197,7 +198,9 @@ import {
197
198
  } from './services/liveTesting.ts';
198
199
 
199
200
  import {
201
+ emitContentCompleted,
200
202
  emitProgressSaved,
203
+ onContentCompleted,
201
204
  onProgressSaved
202
205
  } from './services/progress-events.js';
203
206
 
@@ -467,6 +470,7 @@ export {
467
470
  deleteUserActivity,
468
471
  duplicatePlaylist,
469
472
  editComment,
473
+ emitContentCompleted,
470
474
  emitProgressSaved,
471
475
  enrollUserInGuidedCourse,
472
476
  extractSanityUrl,
@@ -650,6 +654,8 @@ export {
650
654
  markNotificationAsUnread,
651
655
  markThreadAsRead,
652
656
  numberOfActiveUsers,
657
+ onContentCompleted,
658
+ onContentCompletedLearningPathListener,
653
659
  onProgressSaved,
654
660
  openComment,
655
661
  otherStats,
@@ -12,31 +12,31 @@ import {
12
12
  getAllCompletedByIds,
13
13
  getProgressState,
14
14
  } from '../contentProgress.js'
15
- import { COLLECTION_TYPE, STATE } from "../sync/models/ContentProgress";
16
- import { SyncWriteDTO } from "../sync";
17
- import { ContentProgress } from "../sync/models";
15
+ import { COLLECTION_TYPE, STATE } from '../sync/models/ContentProgress'
16
+ import { SyncWriteDTO } from '../sync'
17
+ import { ContentProgress } from '../sync/models'
18
18
 
19
19
  const BASE_PATH: string = `/api/content-org`
20
20
  const LEARNING_PATHS_PATH = `${BASE_PATH}/v1/user/learning-paths`
21
21
 
22
22
  interface ActiveLearningPathResponse {
23
- user_id: number,
24
- brand: string,
25
- active_learning_path_id: number,
23
+ user_id: number
24
+ brand: string
25
+ active_learning_path_id: number
26
26
  }
27
27
 
28
28
  interface DailySessionResponse {
29
- user_id: number,
30
- brand: string,
29
+ user_id: number
30
+ brand: string
31
31
  user_date: string
32
- daily_session: DailySession[],
33
- active_learning_path_id: number,
34
- active_learning_path_created_at: string,
32
+ daily_session: DailySession[]
33
+ active_learning_path_id: number
34
+ active_learning_path_created_at: string
35
35
  }
36
36
 
37
37
  interface DailySession {
38
- content_ids: number[],
39
- learning_path_id: number,
38
+ content_ids: number[]
39
+ learning_path_id: number
40
40
  }
41
41
 
42
42
  /**
@@ -47,7 +47,7 @@ interface DailySession {
47
47
  export async function getDailySession(brand: string, userDate: Date) {
48
48
  const stringDate = userDate.toISOString().split('T')[0]
49
49
  const url: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${stringDate}`
50
- return await fetchHandler(url, 'GET', null, null) as DailySessionResponse
50
+ return (await fetchHandler(url, 'GET', null, null)) as DailySessionResponse
51
51
  }
52
52
 
53
53
  /**
@@ -64,7 +64,7 @@ export async function updateDailySession(
64
64
  const stringDate = userDate.toISOString().split('T')[0]
65
65
  const url: string = `${LEARNING_PATHS_PATH}/daily-session/create`
66
66
  const body = { brand: brand, userDate: stringDate, keepFirstLearningPath: keepFirstLearningPath }
67
- return await fetchHandler(url, 'POST', null, body) as DailySessionResponse
67
+ return (await fetchHandler(url, 'POST', null, body)) as DailySessionResponse
68
68
  }
69
69
 
70
70
  /**
@@ -73,7 +73,7 @@ export async function updateDailySession(
73
73
  */
74
74
  export async function getActivePath(brand: string) {
75
75
  const url: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
76
- return await fetchHandler(url, 'GET', null, null) as ActiveLearningPathResponse
76
+ return (await fetchHandler(url, 'GET', null, null)) as ActiveLearningPathResponse
77
77
  }
78
78
 
79
79
  /**
@@ -84,7 +84,7 @@ export async function getActivePath(brand: string) {
84
84
  export async function startLearningPath(brand: string, learningPathId: number) {
85
85
  const url: string = `${LEARNING_PATHS_PATH}/active-path/set`
86
86
  const body = { brand: brand, learning_path_id: learningPathId }
87
- return await fetchHandler(url, 'POST', null, body) as ActiveLearningPathResponse
87
+ return (await fetchHandler(url, 'POST', null, body)) as ActiveLearningPathResponse
88
88
  }
89
89
 
90
90
  /**
@@ -227,8 +227,6 @@ export async function fetchLearningPathLessons(
227
227
  }))
228
228
  }
229
229
 
230
-
231
-
232
230
  return {
233
231
  ...learningPath,
234
232
  is_active_learning_path: isActiveLearningPath,
@@ -250,14 +248,16 @@ export async function fetchLearningPathLessons(
250
248
  * @param {number[]} contentIds The array of content IDs within the learning path
251
249
  * @returns {Promise<number[]>} Array with completed content IDs
252
250
  */
253
- export async function fetchLearningPathProgressCheckLessons(contentIds: number[]): Promise<number[]> {
251
+ export async function fetchLearningPathProgressCheckLessons(
252
+ contentIds: number[]
253
+ ): Promise<number[]> {
254
254
  let query = await getAllCompletedByIds(contentIds)
255
- let completedProgress = query.data.map(progress => progress.content_id)
256
- return contentIds.filter(contentId => completedProgress.includes(contentId))
255
+ let completedProgress = query.data.map((progress) => progress.content_id)
256
+ return contentIds.filter((contentId) => completedProgress.includes(contentId))
257
257
  }
258
258
 
259
259
  interface completeMethodIntroVideo {
260
- intro_video_response: SyncWriteDTO<ContentProgress, any> | null,
260
+ intro_video_response: SyncWriteDTO<ContentProgress, any> | null
261
261
  active_path_response: ActiveLearningPathResponse
262
262
  }
263
263
  /**
@@ -268,7 +268,10 @@ interface completeMethodIntroVideo {
268
268
  * @returns {Promise<Object|null>} response.intro_video_response - The intro video completion response or null if already completed.
269
269
  * @returns {Promise<Object>} response.active_path_response - The set active learning path response.
270
270
  */
271
- export async function completeMethodIntroVideo(introVideoId: number, brand: string): Promise<completeMethodIntroVideo> {
271
+ export async function completeMethodIntroVideo(
272
+ introVideoId: number,
273
+ brand: string
274
+ ): Promise<completeMethodIntroVideo> {
272
275
  let response = {} as completeMethodIntroVideo
273
276
 
274
277
  response.intro_video_response = await completeIfNotCompleted(introVideoId)
@@ -278,13 +281,12 @@ export async function completeMethodIntroVideo(introVideoId: number, brand: stri
278
281
 
279
282
  response.active_path_response = await startLearningPath(brand, learningPathId)
280
283
 
281
-
282
284
  return response
283
285
  }
284
286
 
285
287
  interface completeLearningPathIntroVideo {
286
- intro_video_response: SyncWriteDTO<ContentProgress, any> | null,
287
- learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null,
288
+ intro_video_response: SyncWriteDTO<ContentProgress, any> | null
289
+ learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null
288
290
  lesson_import_response: SyncWriteDTO<ContentProgress, any> | null
289
291
  }
290
292
  /**
@@ -297,7 +299,11 @@ interface completeLearningPathIntroVideo {
297
299
  * @returns {Promise<void>} response.learning_path_reset_response - The reset learning path response.
298
300
  * @returns {Promise<Object[]>} response.lesson_import_response - The responses for completing each content_id within the learning path.
299
301
  */
300
- export async function completeLearningPathIntroVideo(introVideoId: number, learningPathId: number, lessonsToImport: number[] | null) {
302
+ export async function completeLearningPathIntroVideo(
303
+ introVideoId: number,
304
+ learningPathId: number,
305
+ lessonsToImport: number[] | null
306
+ ) {
301
307
  let response = {} as completeLearningPathIntroVideo
302
308
 
303
309
  response.intro_video_response = await completeIfNotCompleted(introVideoId)
@@ -306,7 +312,6 @@ export async function completeLearningPathIntroVideo(introVideoId: number, learn
306
312
 
307
313
  if (!lessonsToImport) {
308
314
  response.learning_path_reset_response = await contentStatusReset(learningPathId, collection)
309
-
310
315
  } else {
311
316
  response.lesson_import_response = await contentsStatusCompleted(lessonsToImport, collection)
312
317
  }
@@ -314,9 +319,39 @@ export async function completeLearningPathIntroVideo(introVideoId: number, learn
314
319
  return response
315
320
  }
316
321
 
317
-
318
- async function completeIfNotCompleted(contentId: number): Promise<SyncWriteDTO<ContentProgress, any> | null> {
322
+ async function completeIfNotCompleted(
323
+ contentId: number
324
+ ): Promise<SyncWriteDTO<ContentProgress, any> | null> {
319
325
  const introVideoStatus = await getProgressState(contentId)
320
326
 
321
327
  return introVideoStatus !== 'completed' ? await contentStatusCompleted(contentId) : null
322
328
  }
329
+
330
+ export async function onContentCompletedLearningPathListener(event) {
331
+ console.log('if')
332
+ if (event?.collection?.type !== 'learning-path-v2') return
333
+ if (event.contentId !== event?.collection?.id) return
334
+ const learningPathId = event.contentId
335
+ const learningPath = await getEnrichedLearningPath(learningPathId)
336
+ console.log('LP', learningPath)
337
+ const brand = learningPath.brand
338
+ const activeLearningPath = await getActivePath(brand)
339
+ console.log('Active LP', activeLearningPath)
340
+ if (activeLearningPath.active_learning_path_id !== learningPathId) return
341
+ const method = await fetchMethodV2Structure(brand)
342
+ console.log('Method', method)
343
+ const currentIndex = method.learning_paths.findIndex((lp) => lp.id === learningPathId)
344
+ if (currentIndex === -1) {
345
+ return
346
+ }
347
+ const nextLearningPath = method.learning_paths[currentIndex + 1]
348
+ console.log('Next LP', nextLearningPath)
349
+ if (!nextLearningPath) {
350
+ return
351
+ }
352
+
353
+ await startLearningPath(brand, nextLearningPath.id)
354
+ const nextLearningPathData = await getEnrichedLearningPath(nextLearningPath.id)
355
+ console.log('Next LP Data', nextLearningPathData)
356
+ await contentStatusReset(nextLearningPathData.intro_video.id)
357
+ }
@@ -1,8 +1,9 @@
1
1
  import { fetchHierarchy, fetchLearningPathHierarchy } from './sanity.js'
2
2
  import { db } from './sync'
3
- import {COLLECTION_TYPE, 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
+ import { emitContentCompleted } from './progress-events'
6
7
 
7
8
  const STATE_STARTED = STATE.STARTED
8
9
  const STATE_COMPLETED = STATE.COMPLETED
@@ -17,7 +18,12 @@ export async function getProgressStateByIds(contentIds, collection = null) {
17
18
  }
18
19
 
19
20
  export async function getResumeTimeSecondsByIds(contentIds, collection = null) {
20
- return getByIds(normalizeContentIds(contentIds), normalizeCollection(collection), 'resume_time_seconds', 0)
21
+ return getByIds(
22
+ normalizeContentIds(contentIds),
23
+ normalizeCollection(collection),
24
+ 'resume_time_seconds',
25
+ 0
26
+ )
21
27
  }
22
28
 
23
29
  export async function getNavigateTo(data, collection = null) {
@@ -54,23 +60,45 @@ export async function getNavigateTo(data, collection = null) {
54
60
  const firstChild = validChildren[0]
55
61
  let lastInteractedChildNavToData = await getNavigateTo([firstChild], collection)
56
62
  lastInteractedChildNavToData = lastInteractedChildNavToData[firstChild.id] ?? null
57
- navigateToData[content.id] = buildNavigateTo(firstChild, lastInteractedChildNavToData, collection) //no G-child for LP
63
+ navigateToData[content.id] = buildNavigateTo(
64
+ firstChild,
65
+ lastInteractedChildNavToData,
66
+ collection
67
+ ) //no G-child for LP
58
68
  } else {
59
69
  const childrenStates = await getProgressStateByIds(childrenIds, collection)
60
70
  const lastInteracted = await getLastInteractedOf(childrenIds, collection)
61
71
  const lastInteractedStatus = childrenStates[lastInteracted]
62
72
 
63
73
  if (['course', 'pack-bundle', 'skill-pack'].includes(content.type)) {
64
- if (lastInteractedStatus === STATE_STARTED) { // send to last interacted
65
- navigateToData[content.id] = buildNavigateTo(children.get(lastInteracted), null, collection)
66
- } else { // send to first incomplete after last interacted
74
+ if (lastInteractedStatus === STATE_STARTED) {
75
+ // send to last interacted
76
+ navigateToData[content.id] = buildNavigateTo(
77
+ children.get(lastInteracted),
78
+ null,
79
+ collection
80
+ )
81
+ } else {
82
+ // send to first incomplete after last interacted
67
83
  let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
68
- navigateToData[content.id] = buildNavigateTo(children.get(incompleteChild), null, collection)
84
+ navigateToData[content.id] = buildNavigateTo(
85
+ children.get(incompleteChild),
86
+ null,
87
+ collection
88
+ )
69
89
  }
70
- } else if (['song-tutorial', 'guided-course', COLLECTION_TYPE.LEARNING_PATH].includes(content.type)) { // send to first incomplete
90
+ } else if (
91
+ ['song-tutorial', 'guided-course', COLLECTION_TYPE.LEARNING_PATH].includes(content.type)
92
+ ) {
93
+ // send to first incomplete
71
94
  let incompleteChild = findIncompleteLesson(childrenStates, lastInteracted, content.type)
72
- navigateToData[content.id] = buildNavigateTo(children.get(incompleteChild), null, collection)
73
- } else if (twoDepthContentTypes.includes(content.type)) { // send to navigateTo child of last interacted child
95
+ navigateToData[content.id] = buildNavigateTo(
96
+ children.get(incompleteChild),
97
+ null,
98
+ collection
99
+ )
100
+ } else if (twoDepthContentTypes.includes(content.type)) {
101
+ // send to navigateTo child of last interacted child
74
102
  const firstChildren = content.children ?? []
75
103
  const lastInteractedChildId = await getLastInteractedOf(
76
104
  firstChildren.map((child) => child.id),
@@ -117,21 +145,28 @@ function buildNavigateTo(content, child = null, collection = null) {
117
145
  * @returns {Promise<number>}
118
146
  */
119
147
  export async function getLastInteractedOf(contentIds, collection = null) {
120
- return db.contentProgress.mostRecentlyUpdatedId(normalizeContentIds(contentIds), normalizeCollection(collection)).then(r => r.data ? parseInt(r.data) : undefined)
148
+ return db.contentProgress
149
+ .mostRecentlyUpdatedId(normalizeContentIds(contentIds), normalizeCollection(collection))
150
+ .then((r) => (r.data ? parseInt(r.data) : undefined))
121
151
  }
122
152
 
123
153
  export async function getProgressDataByIds(contentIds, collection) {
124
154
  contentIds = normalizeContentIds(contentIds)
125
155
  collection = normalizeCollection(collection)
126
156
 
127
- const progress = Object.fromEntries(contentIds.map(id => [id, {
128
- last_update: 0,
129
- progress: 0,
130
- status: '',
131
- }]))
157
+ const progress = Object.fromEntries(
158
+ contentIds.map((id) => [
159
+ id,
160
+ {
161
+ last_update: 0,
162
+ progress: 0,
163
+ status: '',
164
+ },
165
+ ])
166
+ )
132
167
 
133
- await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
134
- r.data.forEach(p => {
168
+ await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
169
+ r.data.forEach((p) => {
135
170
  progress[p.content_id] = {
136
171
  last_update: p.updated_at,
137
172
  progress: p.progress_percent,
@@ -145,15 +180,17 @@ export async function getProgressDataByIds(contentIds, collection) {
145
180
 
146
181
  async function getById(contentId, dataKey, defaultValue) {
147
182
  if (!contentId) return defaultValue
148
- return db.contentProgress.getOneProgressByContentId(contentId).then(r => r.data?.[dataKey] ?? defaultValue)
183
+ return db.contentProgress
184
+ .getOneProgressByContentId(contentId)
185
+ .then((r) => r.data?.[dataKey] ?? defaultValue)
149
186
  }
150
187
 
151
188
  async function getByIds(contentIds, collection, dataKey, defaultValue) {
152
189
  if (contentIds.length === 0) return {}
153
190
 
154
- const progress = Object.fromEntries(contentIds.map(id => [id, defaultValue]))
155
- await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
156
- r.data.forEach(p => {
191
+ const progress = Object.fromEntries(contentIds.map((id) => [id, defaultValue]))
192
+ await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
193
+ r.data.forEach((p) => {
157
194
  progress[p.content_id] = p[dataKey] ?? defaultValue
158
195
  })
159
196
  })
@@ -161,11 +198,11 @@ async function getByIds(contentIds, collection, dataKey, defaultValue) {
161
198
  }
162
199
 
163
200
  export async function getAllStarted(limit = null) {
164
- return db.contentProgress.startedIds(limit).then(r => r.data.map(id => parseInt(id)))
201
+ return db.contentProgress.startedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
165
202
  }
166
203
 
167
204
  export async function getAllCompleted(limit = null) {
168
- return db.contentProgress.completedIds(limit).then(r => r.data.map(id => parseInt(id)))
205
+ return db.contentProgress.completedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
169
206
  }
170
207
 
171
208
  export async function getAllCompletedByIds(contentIds) {
@@ -175,7 +212,7 @@ export async function getAllCompletedByIds(contentIds) {
175
212
  export async function getAllStartedOrCompleted({
176
213
  onlyIds = true,
177
214
  brand = null,
178
- limit = null
215
+ limit = null,
179
216
  } = {}) {
180
217
  const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
181
218
  const filters = {
@@ -185,15 +222,22 @@ export async function getAllStartedOrCompleted({
185
222
  }
186
223
 
187
224
  if (onlyIds) {
188
- return db.contentProgress.startedOrCompletedIds(filters).then(r => r.data.map(id => parseInt(id)))
225
+ return db.contentProgress
226
+ .startedOrCompletedIds(filters)
227
+ .then((r) => r.data.map((id) => parseInt(id)))
189
228
  } else {
190
- return db.contentProgress.startedOrCompleted(filters).then(r => {
191
- return Object.fromEntries(r.data.map(p => [p.content_id, {
192
- last_update: p.updated_at,
193
- progress: p.progress_percent,
194
- status: p.state,
195
- brand: p.content_brand,
196
- }]))
229
+ return db.contentProgress.startedOrCompleted(filters).then((r) => {
230
+ return Object.fromEntries(
231
+ r.data.map((p) => [
232
+ p.content_id,
233
+ {
234
+ last_update: p.updated_at,
235
+ progress: p.progress_percent,
236
+ status: p.state,
237
+ brand: p.content_brand,
238
+ },
239
+ ])
240
+ )
197
241
  })
198
242
  }
199
243
  }
@@ -216,8 +260,8 @@ export async function getAllStartedOrCompleted({
216
260
  * console.log(progressMap[123]); // => 52
217
261
  */
218
262
  export async function getStartedOrCompletedProgressOnly({ brand = undefined } = {}) {
219
- return db.contentProgress.startedOrCompleted({ brand: brand }).then(r => {
220
- return Object.fromEntries(r.data.map(p => [p.content_id, p.progress_percent]))
263
+ return db.contentProgress.startedOrCompleted({ brand: brand }).then((r) => {
264
+ return Object.fromEntries(r.data.map((p) => [p.content_id, p.progress_percent]))
221
265
  })
222
266
  }
223
267
 
@@ -256,9 +300,7 @@ export async function recordWatchSession(
256
300
  async function trackPractice(contentId, secondsPlayed, prevSession, details = {}) {
257
301
  const session = prevSession || new Map()
258
302
 
259
- const secondsSinceLastUpdate = Math.ceil(
260
- secondsPlayed - (session.get(contentId) ?? 0)
261
- )
303
+ const secondsSinceLastUpdate = Math.ceil(secondsPlayed - (session.get(contentId) ?? 0))
262
304
  session.set(contentId, secondsPlayed)
263
305
 
264
306
  await trackUserPractice(contentId, secondsSinceLastUpdate, details)
@@ -274,43 +316,78 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
274
316
  }
275
317
 
276
318
  export async function contentStatusCompleted(contentId, collection = null) {
277
- return setStartedOrCompletedStatus(normalizeContentId(contentId), normalizeCollection(collection), true)
319
+ return setStartedOrCompletedStatus(
320
+ normalizeContentId(contentId),
321
+ normalizeCollection(collection),
322
+ true
323
+ )
278
324
  }
279
325
 
280
326
  export async function contentsStatusCompleted(contentIds, collection = null) {
281
- return setStartedOrCompletedStatuses(normalizeContentIds(contentIds), normalizeCollection(collection), true)
327
+ return setStartedOrCompletedStatuses(
328
+ normalizeContentIds(contentIds),
329
+ normalizeCollection(collection),
330
+ true
331
+ )
282
332
  }
283
333
 
284
334
  export async function contentStatusStarted(contentId, collection = null) {
285
- return setStartedOrCompletedStatus(normalizeContentId(contentId), normalizeCollection(collection), false)
335
+ return setStartedOrCompletedStatus(
336
+ normalizeContentId(contentId),
337
+ normalizeCollection(collection),
338
+ false
339
+ )
286
340
  }
287
341
  export async function contentStatusReset(contentId, collection = null) {
288
342
  return resetStatus(contentId, collection)
289
343
  }
290
344
 
291
345
  async function saveContentProgress(contentId, collection, progress, currentSeconds) {
292
- const response = await db.contentProgress.recordProgress(contentId, collection, progress, currentSeconds)
346
+ const response = await db.contentProgress.recordProgress(
347
+ contentId,
348
+ collection,
349
+ progress,
350
+ currentSeconds
351
+ )
352
+ if (progress === 100) emitContentCompleted(contentId, collection)
293
353
 
294
354
  // note - previous implementation explicitly did not trickle progress to children here
295
355
  // (only to siblings/parents via le bubbles)
296
356
 
297
- const bubbledProgresses = await bubbleProgress(await getHierarchy(contentId, collection), contentId, collection)
357
+ const bubbledProgresses = await bubbleProgress(
358
+ await getHierarchy(contentId, collection),
359
+ contentId,
360
+ collection
361
+ )
298
362
  await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
299
363
 
364
+ for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
365
+ if (bubbledProgress === 100) {
366
+ emitContentCompleted(Number(bubbledContentId), collection)
367
+ }
368
+ }
300
369
  return response
301
370
  }
302
371
 
303
372
  async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
304
373
  const progress = isCompleted ? 100 : 0
305
374
  const response = await db.contentProgress.recordProgress(contentId, collection, progress)
306
-
375
+ if (progress === 100) emitContentCompleted(contentId, collection)
307
376
  const hierarchy = await getHierarchy(contentId, collection)
308
-
309
377
  await Promise.all([
310
- db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, progress), collection),
311
- bubbleProgress(hierarchy, contentId, collection).then(bubbledProgresses => db.contentProgress.recordProgressesTentative(bubbledProgresses, collection))
378
+ db.contentProgress.recordProgressesTentative(
379
+ trickleProgress(hierarchy, contentId, collection, progress),
380
+ collection
381
+ ),
382
+ bubbleProgress(hierarchy, contentId, collection).then(async (bubbledProgresses) => {
383
+ await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
384
+ for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
385
+ if (bubbledProgress === 100) {
386
+ emitContentCompleted(Number(bubbledContentId), collection)
387
+ }
388
+ }
389
+ }),
312
390
  ])
313
-
314
391
  return response
315
392
  }
316
393
 
@@ -337,13 +414,11 @@ async function setStartedOrCompletedStatuses(contentIds, collection, isCompleted
337
414
  ids = {
338
415
  ...ids,
339
416
  ...trickleProgress(hierarchy, contentId, collection, progress),
340
- ...await bubbleProgress(hierarchy, contentId, collection)
417
+ ...(await bubbleProgress(hierarchy, contentId, collection)),
341
418
  }
342
419
  }
343
420
 
344
- await Promise.all([
345
- db.contentProgress.recordProgressesTentative(ids, collection),
346
- ]);
421
+ await Promise.all([db.contentProgress.recordProgressesTentative(ids, collection)])
347
422
 
348
423
  return response
349
424
  }
@@ -353,8 +428,13 @@ async function resetStatus(contentId, collection = null) {
353
428
  const hierarchy = await getHierarchy(contentId, collection)
354
429
 
355
430
  await Promise.all([
356
- db.contentProgress.recordProgressesTentative(trickleProgress(hierarchy, contentId, collection, 0), collection),
357
- bubbleProgress(hierarchy, contentId, collection).then(bubbledProgresses => db.contentProgress.recordProgressesTentative(bubbledProgresses, collection))
431
+ db.contentProgress.recordProgressesTentative(
432
+ trickleProgress(hierarchy, contentId, collection, 0),
433
+ collection
434
+ ),
435
+ bubbleProgress(hierarchy, contentId, collection).then((bubbledProgresses) =>
436
+ db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
437
+ ),
358
438
  ])
359
439
 
360
440
  return response
@@ -364,10 +444,10 @@ async function resetStatus(contentId, collection = null) {
364
444
  // as long as callers remember to pass collection where needed
365
445
  function trickleProgress(hierarchy, contentId, _collection, progress) {
366
446
  const descendantIds = getChildrenToDepth(contentId, hierarchy, MAX_DEPTH)
367
- return Object.fromEntries(descendantIds.map(id => [id, progress]))
447
+ return Object.fromEntries(descendantIds.map((id) => [id, progress]))
368
448
  }
369
449
 
370
- async function bubbleProgress(hierarchy, contentId, collection = null) {
450
+ async function bubbleProgress(hierarchy, contentId, collection = null) {
371
451
  const ids = getAncestorAndSiblingIds(hierarchy, contentId)
372
452
  const progresses = await getByIds(ids, collection, 'progress_percent', 0)
373
453
  return averageProgressesFor(hierarchy, contentId, progresses)
@@ -386,7 +466,7 @@ function getAncestorAndSiblingIds(hierarchy, contentId, depth = 1) {
386
466
 
387
467
  return [
388
468
  ...(hierarchy?.children?.[parentId] ?? []),
389
- ...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1)
469
+ ...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1),
390
470
  ]
391
471
  }
392
472
 
@@ -398,10 +478,13 @@ function averageProgressesFor(hierarchy, contentId, progressData, depth = 1) {
398
478
  const parentId = hierarchy?.parents?.[contentId]
399
479
  if (!parentId) return {}
400
480
 
401
- const parentChildProgress = hierarchy?.children?.[parentId]?.map(childId => {
481
+ const parentChildProgress = hierarchy?.children?.[parentId]?.map((childId) => {
402
482
  return progressData[childId] ?? 0
403
483
  })
404
- const avgParentProgress = parentChildProgress.length > 0 ? Math.round(parentChildProgress.reduce((a, b) => a + b, 0) / parentChildProgress.length) : 0
484
+ const avgParentProgress =
485
+ parentChildProgress.length > 0
486
+ ? Math.round(parentChildProgress.reduce((a, b) => a + b, 0) / parentChildProgress.length)
487
+ : 0
405
488
 
406
489
  return {
407
490
  ...averageProgressesFor(hierarchy, parentId, progressData, depth + 1),
@@ -426,7 +509,7 @@ function normalizeContentId(contentId) {
426
509
  }
427
510
 
428
511
  function normalizeContentIds(contentIds) {
429
- return contentIds.map(id => normalizeContentId(id))
512
+ return contentIds.map((id) => normalizeContentId(id))
430
513
  }
431
514
 
432
515
  function normalizeCollection(collection) {
@@ -435,7 +518,7 @@ function normalizeCollection(collection) {
435
518
  if (!Object.values(COLLECTION_TYPE).includes(collection.type)) {
436
519
  throw new Error(`Invalid collection type: ${collection.type}`)
437
520
  }
438
-
521
+
439
522
  if (typeof collection.id === 'string' && isNaN(+collection.id)) {
440
523
  throw new Error(`Invalid collection id: ${collection.id}`)
441
524
  }
@@ -48,7 +48,7 @@ export function onProgressSaved(listener) {
48
48
  * @returns {void}
49
49
  */
50
50
  export function emitProgressSaved(event) {
51
- listeners.forEach(listener => {
51
+ listeners.forEach((listener) => {
52
52
  try {
53
53
  listener(event)
54
54
  } catch (error) {
@@ -56,3 +56,55 @@ export function emitProgressSaved(event) {
56
56
  }
57
57
  })
58
58
  }
59
+
60
+ /**
61
+ * @typedef {Object} ContentCompletedEvent
62
+ * @property {number} contentId - Railcontent ID of the completed content item
63
+ * @property {Object|null} collection - Collection context information
64
+ * @property {string} collection.type - Collection type (learning-path, guided-course, etc.)
65
+ * @property {number} collection.id - Collection ID
66
+ */
67
+
68
+ /**
69
+ * @callback ContentCompletedListener
70
+ * @param {ContentCompletedEvent} event - The content completion event data
71
+ * @returns {void}
72
+ */
73
+ const completedListeners = new Set()
74
+
75
+ /**
76
+ * @param {ContentCompletedListener} listener - Function called when content is completed
77
+ * @returns {function(): void} Cleanup function to unregister the listener
78
+ *
79
+ * @example Listen for content completion
80
+ * const cleanup = onContentCompleted((event) => {
81
+ * console.log(`Content ${event.contentId} completed!`)
82
+ * if (event.collection) {
83
+ * console.log(`Within ${event.collection.type}: ${event.collection.id}`)
84
+ * checkCollectionProgress(event.collection.id)
85
+ * }
86
+ * })
87
+ *
88
+ * // Later, when no longer needed:
89
+ * cleanup()
90
+ */
91
+ export function onContentCompleted(listener) {
92
+ completedListeners.add(listener)
93
+ return () => completedListeners.delete(listener)
94
+ }
95
+
96
+ /**
97
+ * @param {number} contentId - The ID of the completed content item
98
+ * @param {Object|null} collection - Collection context information
99
+ * @returns {void}
100
+ */
101
+ export function emitContentCompleted(contentId, collection) {
102
+ const event = { contentId: contentId, collection: collection }
103
+ completedListeners.forEach((listener) => {
104
+ try {
105
+ listener(event)
106
+ } catch (error) {
107
+ console.error('Error in contentCompleted listener:', error)
108
+ }
109
+ })
110
+ }
@@ -14,6 +14,9 @@ import { inBoundary } from './errors/boundary'
14
14
  import createStoresFromConfig from './store-configs'
15
15
  import { contentProgressObserver } from '../awards/internal/content-progress-observer'
16
16
 
17
+ import { onProgressSaved, onContentCompleted } from '../progress-events'
18
+ import { onContentCompletedLearningPathListener } from '../content-org/learning-paths'
19
+
17
20
  export default class SyncManager {
18
21
  private static counter = 0
19
22
  private static instance: SyncManager | null = null
@@ -30,6 +33,10 @@ export default class SyncManager {
30
33
  }
31
34
  }
32
35
 
36
+ public static getInstanceOrNull() {
37
+ return SyncManager.instance
38
+ }
39
+
33
40
  public static getInstance(): SyncManager {
34
41
  if (!SyncManager.instance) {
35
42
  throw new SyncError('SyncManager not initialized')
@@ -72,17 +79,26 @@ export default class SyncManager {
72
79
  }
73
80
 
74
81
  createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
75
- return new SyncStore<TModel>(config, this.context, this.database, this.retry, this.runScope, this.telemetry)
82
+ return new SyncStore<TModel>(
83
+ config,
84
+ this.context,
85
+ this.database,
86
+ this.retry,
87
+ this.runScope,
88
+ this.telemetry
89
+ )
76
90
  }
77
91
 
78
92
  registerStores<TModel extends BaseModel>(stores: SyncStore<TModel>[]) {
79
- return Object.fromEntries(stores.map(store => {
80
- return [store.model.table, store]
81
- })) as Record<string, SyncStore<TModel>>
93
+ return Object.fromEntries(
94
+ stores.map((store) => {
95
+ return [store.model.table, store]
96
+ })
97
+ ) as Record<string, SyncStore<TModel>>
82
98
  }
83
99
 
84
100
  storesForModels(models: ModelClass[]) {
85
- return models.map(model => this.storesRegistry[model.table])
101
+ return models.map((model) => this.storesRegistry[model.table])
86
102
  }
87
103
 
88
104
  createStrategy<T extends SyncStrategy, U extends any[]>(
@@ -96,11 +112,8 @@ export default class SyncManager {
96
112
  this.strategyMap.push({ stores, strategies })
97
113
  }
98
114
 
99
- protectStores(
100
- stores: SyncStore<any>[],
101
- mechanisms: SyncConcurrencySafetyMechanism[]
102
- ) {
103
- const teardowns = mechanisms.map(mechanism => mechanism(this.context, stores))
115
+ protectStores(stores: SyncStore<any>[], mechanisms: SyncConcurrencySafetyMechanism[]) {
116
+ const teardowns = mechanisms.map((mechanism) => mechanism(this.context, stores))
104
117
  this.safetyMap.push({ stores, mechanisms: teardowns })
105
118
  }
106
119
 
@@ -111,9 +124,9 @@ export default class SyncManager {
111
124
  this.retry.start()
112
125
 
113
126
  this.strategyMap.forEach(({ stores, strategies }) => {
114
- strategies.forEach(strategy => {
115
- stores.forEach(store => {
116
- strategy.onTrigger(store, reason => {
127
+ strategies.forEach((strategy) => {
128
+ stores.forEach((store) => {
129
+ strategy.onTrigger(store, (reason) => {
117
130
  store.requestSync(reason)
118
131
  })
119
132
  })
@@ -121,15 +134,18 @@ export default class SyncManager {
121
134
  })
122
135
  })
123
136
 
124
- contentProgressObserver.start(this.database).catch(error => {
137
+ contentProgressObserver.start(this.database).catch((error) => {
125
138
  this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
126
139
  })
140
+ onContentCompleted(onContentCompletedLearningPathListener)
127
141
 
128
142
  const teardown = async () => {
129
143
  this.telemetry.debug('[SyncManager] Tearing down')
130
144
  this.runScope.abort()
131
- this.strategyMap.forEach(({ strategies }) => strategies.forEach(strategy => strategy.stop()))
132
- this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach(mechanism => mechanism()))
145
+ this.strategyMap.forEach(({ strategies }) =>
146
+ strategies.forEach((strategy) => strategy.stop())
147
+ )
148
+ this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach((mechanism) => mechanism()))
133
149
  contentProgressObserver.stop()
134
150
  this.retry.stop()
135
151
  this.context.stop()
@@ -270,7 +270,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
270
270
  if (recreate) {
271
271
  return this.collection.prepareCreate(record => {
272
272
  record._raw.id = id
273
- record._raw.created_at = recreate.created_at
273
+ record._raw.created_at = recreate.created_at as EpochMs
274
274
  record._raw.updated_at = this.generateTimestamp()
275
275
  record._raw._status = 'created'
276
276
  builder(record)
@@ -1,5 +1,6 @@
1
1
  import { globalConfig, initializeService } from '../src'
2
2
  import { LocalStorageMock } from './localStorageMock'
3
+ import { initializeSyncManager } from './sync/initialize-sync-manager'
3
4
  const railContentModule = require('../src/services/railcontent.js')
4
5
  let token = null
5
6
  let userId = process.env.RAILCONTENT_USER_ID ?? null
@@ -43,7 +44,7 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
43
44
  baseUrl: process.env.RAILCONTENT_BASE_URL || 'https://test.musora.com',
44
45
  token: token,
45
46
  userId: userId,
46
- authToken: token
47
+ authToken: token,
47
48
  },
48
49
  sessionConfig: { token: token, userId: userId, authToken: token },
49
50
  baseUrl: process.env.RAILCONTENT_BASE_URL,
@@ -51,9 +52,10 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
51
52
  isMA: true,
52
53
  }
53
54
  initializeService(config)
54
-
55
55
  // Mock user permissions
56
56
  let permissionsMock = jest.spyOn(railContentModule, 'fetchUserPermissionsData')
57
57
  let permissionsData = { permissions: [108, 91, 92], isAdmin: isAdmin }
58
58
  permissionsMock.mockImplementation(() => permissionsData)
59
+
60
+ initializeSyncManager(userId)
59
61
  }
@@ -1,21 +1,70 @@
1
1
  import { initializeTestService } from './initializeTests.js'
2
2
  import {
3
3
  fetchLearningPathLessons,
4
- getLearningPath,
4
+ getEnrichedLearningPath,
5
+ startLearningPath,
6
+ resetAllLearningPaths,
7
+ getActivePath,
5
8
  } from '../src/services/content-org/learning-paths.ts'
6
- import { contentStatusCompleted } from '../src/services/contentProgress.js'
9
+ import {
10
+ contentStatusCompleted,
11
+ contentStatusReset,
12
+ getProgressDataByIds,
13
+ } from '../src/services/contentProgress.js'
7
14
  describe('learning-paths', function () {
8
15
  beforeEach(async () => {
9
16
  await initializeTestService(true)
10
17
  })
11
18
 
12
- test('getLearningPathsV2Test', async () => {
13
- const results = await getLearningPath(417140)
14
- })
15
- test('getlearningPathLessonsTestNew', async () => {
16
- await contentStatusCompleted(417105)
17
- const userDate = new Date('2025-10-31')
18
- const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
19
- console.log(results)
19
+ afterEach(async () => {
20
+ // Flush all pending promises
21
+ await new Promise((resolve) => setImmediate(resolve))
20
22
  })
23
+
24
+ // test('getLearningPathsV2Test', async () => {
25
+ // const results = await getEnrichedLearningPath(417140)
26
+ // })
27
+ // test('getlearningPathLessonsTestNew', async () => {
28
+ // await contentStatusCompleted(417105)
29
+ // const userDate = new Date('2025-10-31')
30
+ // const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
31
+ // console.log(results)
32
+ // })
33
+ // test('getlearningPathLessonsTestNew', async () => {
34
+ // await contentStatusCompleted(417105)
35
+ // const userDate = new Date('2025-10-31')
36
+ // const results = await fetchLearningPathLessons(422533, 'drumeo', userDate)
37
+ // console.log(results)
38
+ // })
39
+
40
+ test('learningPathCompletion', async () => {
41
+ const learningPathId = 435527
42
+ await contentStatusReset(learningPathId)
43
+ await resetAllLearningPaths()
44
+ await startLearningPath('drumeo', learningPathId)
45
+ const collection = { type: 'learning-path-v2', id: learningPathId }
46
+ const learningPath = await getEnrichedLearningPath(learningPathId)
47
+
48
+ // Complete each child one by one
49
+ for (const child of learningPath.children) {
50
+ await contentStatusReset(child.id)
51
+ await contentStatusCompleted(child.id, collection)
52
+
53
+ // Check child status
54
+ const childProgress = await getProgressDataByIds([child.id], collection)
55
+
56
+ // Check parent status after each child
57
+ const parentProgress = await getProgressDataByIds([learningPathId], collection)
58
+ }
59
+
60
+ // Final check - parent should be completed
61
+ const finalParentProgress = await getProgressDataByIds([learningPathId], collection)
62
+ console.log('\n--- Final parent progress:', finalParentProgress)
63
+ expect(finalParentProgress[learningPathId]?.status).toBe('completed')
64
+
65
+ await new Promise((resolve) => setTimeout(resolve, 5000))
66
+
67
+ const activePath = await getActivePath('drumeo')
68
+ expect(activePath.active_learning_path_id).toBe(435563)
69
+ }, 15000)
21
70
  })
@@ -0,0 +1,41 @@
1
+ import adapterFactory from '../../src/services/sync/adapters/factory'
2
+ import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
3
+ import EventEmitter from '../../src/services/sync/utils/event-emitter'
4
+
5
+ export default function syncStoreAdapter(userId: string, bus: SyncAdapterEventBus) {
6
+ return adapterFactory(LokiJSAdapter, `user:${userId}`, {
7
+ useWebWorker: false,
8
+ useIncrementalIndexedDB: true,
9
+ extraLokiOptions: {
10
+ autosave: true,
11
+ autosaveInterval: 300, // increase for better performance at cost of potential data loss on tab close/crash
12
+ },
13
+ onQuotaExceededError: () => {
14
+ // Browser ran out of disk space or possibly in incognito mode
15
+ // called ONLY at startup
16
+ // ideal place to trigger banner (?) to user when also offline?
17
+ // (so that the non-customizable browser default onbeforeunload confirmation (in offline-unload-warning.ts) has context and makes sense)
18
+ bus.emit('quotaExceededError')
19
+ },
20
+ onSetUpError: () => {
21
+ // TODO - Database failed to load -- offer the user to reload the app or log out
22
+ },
23
+ extraIncrementalIDBOptions: {
24
+ lazyCollections: ['content_like'],
25
+ onDidOverwrite: () => {
26
+ // Called when this adapter is forced to overwrite contents of IndexedDB.
27
+ // This happens if there's another open tab of the same app that's making changes.
28
+ // this scenario is handled-ish in `idb-clobber-avoidance`
29
+ },
30
+ onversionchange: () => {
31
+ // no-op
32
+ // indexeddb was deleted in another browser tab (user logged out), so we must make sure we delete
33
+ // in-memory db in this tab as well,
34
+ // but we rely on sync manager setup/teardown to `unsafeResetDatabase` and redirect for this,
35
+ // though reloading the page might be useful as well
36
+ },
37
+ },
38
+ })
39
+ }
40
+
41
+ export class SyncAdapterEventBus extends EventEmitter<{ quotaExceededError: [] }> {}
@@ -0,0 +1,104 @@
1
+ import { SyncManager, SyncContext } from '../../src/services/sync/index'
2
+ import {
3
+ BaseSessionProvider,
4
+ BaseConnectivityProvider,
5
+ BaseDurabilityProvider,
6
+ BaseTabsProvider,
7
+ BaseVisibilityProvider,
8
+ } from '../../src/services/sync/context/providers/'
9
+ import adapterFactory from '../../src/services/sync/adapters/factory'
10
+ import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
11
+ import EventEmitter from '../../src/services/sync/utils/event-emitter'
12
+ import { InitialStrategy, PollingStrategy } from '../../src/services/sync/strategies/index'
13
+ import { SyncTelemetry, SentryLike } from '../../src/services/sync/telemetry/index'
14
+ import {
15
+ ContentLike,
16
+ ContentProgress,
17
+ Practice,
18
+ PracticeDayNote,
19
+ } from '../../src/services/sync/models/index'
20
+ import syncDatabaseFactory from '../../src/services/sync/database/factory'
21
+
22
+ import syncAdapter, { SyncAdapterEventBus } from './adapter'
23
+
24
+ export function initializeSyncManager(userId) {
25
+ if (SyncManager.getInstanceOrNull()) {
26
+ return
27
+ }
28
+ const dummySentry = {
29
+ captureException: () => '',
30
+ addBreadcrumb: () => {},
31
+ startSpan: (options, callback) => {
32
+ // Return a mock span object with the properties Sentry expects
33
+ const mockSpan = {
34
+ _spanId: 'mock-span-id',
35
+ end: () => {},
36
+ setStatus: () => {},
37
+ setData: () => {},
38
+ setAttribute: () => {},
39
+ }
40
+
41
+ if (callback) {
42
+ return callback(mockSpan)
43
+ }
44
+ return mockSpan
45
+ },
46
+ }
47
+
48
+ SyncTelemetry.setInstance(new SyncTelemetry(userId, { Sentry: dummySentry }))
49
+
50
+ const adapterBus = new SyncAdapterEventBus()
51
+ const adapter = syncAdapter(userId, adapterBus)
52
+ const db = syncDatabaseFactory(adapter)
53
+
54
+ const context = new SyncContext({
55
+ session: {
56
+ getClientId: () => 'test-client-id',
57
+ getSessionId: () => null,
58
+ start: () => {},
59
+ stop: () => {},
60
+ },
61
+ connectivity: {
62
+ getValue: () => true,
63
+ subscribe: () => () => {},
64
+ notifyListeners: () => {},
65
+ start: () => {},
66
+ stop: () => {},
67
+ },
68
+ visibility: {
69
+ getValue: () => true,
70
+ subscribe: () => () => {},
71
+ notifyListeners: () => {},
72
+ start: () => {},
73
+ stop: () => {},
74
+ },
75
+ tabs: {
76
+ hasOtherTabs: () => false,
77
+ broadcast: () => {},
78
+ subscribe: () => () => {},
79
+ start: () => {},
80
+ stop: () => {},
81
+ },
82
+ durability: {
83
+ getValue: () => true,
84
+ start: () => {},
85
+ stop: () => {},
86
+ },
87
+ })
88
+ const manager = new SyncManager(context, db)
89
+
90
+ const initialStrategy = manager.createStrategy(InitialStrategy)
91
+ const aggressivePollingStrategy = manager.createStrategy(PollingStrategy, 3600_000)
92
+
93
+ manager.syncStoresWithStrategies(
94
+ manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
95
+ [initialStrategy, aggressivePollingStrategy]
96
+ )
97
+
98
+ manager.protectStores(
99
+ manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
100
+ []
101
+ )
102
+
103
+ SyncManager.assignAndSetupInstance(manager)
104
+ }