musora-content-services 2.95.5 → 2.96.1

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.
@@ -1,12 +1,12 @@
1
1
  import SyncRepository, { Q } from './base'
2
- import ContentProgress, { COLLECTION_TYPE, STATE } from '../models/ContentProgress'
2
+ import ContentProgress, { COLLECTION_TYPE, COLLECTION_ID_SELF, STATE } from '../models/ContentProgress'
3
3
 
4
4
  export default class ProgressRepository extends SyncRepository<ContentProgress> {
5
5
  // null collection only
6
6
  async startedIds(limit?: number) {
7
7
  return this.queryAllIds(...[
8
- Q.where('collection_type', null),
9
- Q.where('collection_id', null),
8
+ Q.where('collection_type', COLLECTION_TYPE.SELF),
9
+ Q.where('collection_id', COLLECTION_ID_SELF),
10
10
 
11
11
  Q.where('state', STATE.STARTED),
12
12
  Q.sortBy('updated_at', 'desc'),
@@ -18,8 +18,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
18
18
  // null collection only
19
19
  async completedIds(limit?: number) {
20
20
  return this.queryAllIds(...[
21
- Q.where('collection_type', null),
22
- Q.where('collection_id', null),
21
+ Q.where('collection_type', COLLECTION_TYPE.SELF),
22
+ Q.where('collection_id', COLLECTION_ID_SELF),
23
23
 
24
24
  Q.where('state', STATE.COMPLETED),
25
25
  Q.sortBy('updated_at', 'desc'),
@@ -55,8 +55,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
55
55
  } = {}
56
56
  ) {
57
57
  const clauses: Q.Clause[] = [
58
- Q.where('collection_type', null),
59
- Q.where('collection_id', null),
58
+ Q.where('collection_type', COLLECTION_TYPE.SELF),
59
+ Q.where('collection_id', COLLECTION_ID_SELF),
60
60
 
61
61
  Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
62
62
  Q.sortBy('updated_at', 'desc'),
@@ -80,8 +80,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
80
80
  async mostRecentlyUpdatedId(contentIds: number[], collection: { type: COLLECTION_TYPE; id: number } | null = null) {
81
81
  return this.queryOneId(
82
82
  Q.where('content_id', Q.oneOf(contentIds)),
83
- Q.where('collection_type', collection?.type ?? null),
84
- Q.where('collection_id', collection?.id ?? null),
83
+ Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
84
+ Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
85
85
 
86
86
  Q.sortBy('updated_at', 'desc')
87
87
  )
@@ -93,8 +93,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
93
93
  ) {
94
94
  const clauses = [
95
95
  Q.where('content_id', contentId),
96
- Q.where('collection_type', collection?.type ?? null),
97
- Q.where('collection_id', collection?.id ?? null),
96
+ Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
97
+ Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
98
98
  ]
99
99
 
100
100
  return await this.queryOne(...clauses)
@@ -106,8 +106,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
106
106
  ) {
107
107
  const clauses = [
108
108
  Q.where('content_id', Q.oneOf(contentIds)),
109
- Q.where('collection_type', collection?.type ?? null),
110
- Q.where('collection_id', collection?.id ?? null),
109
+ Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
110
+ Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
111
111
  ]
112
112
 
113
113
  return await this.queryAll(...clauses)
@@ -118,8 +118,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
118
118
 
119
119
  const result = this.upsertOne(id, (r) => {
120
120
  r.content_id = contentId
121
- r.collection_type = collection?.type ?? null
122
- r.collection_id = collection?.id ?? null
121
+ r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
122
+ r.collection_id = collection?.id ?? COLLECTION_ID_SELF
123
123
 
124
124
  r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
125
125
  r.progress_percent = progressPct
@@ -142,8 +142,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
142
142
  progressPercent: progressPct,
143
143
  progressStatus: progressPct === 100 ? STATE.COMPLETED : STATE.STARTED,
144
144
  bubble: true,
145
- collectionType: collection?.type ?? null,
146
- collectionId: collection?.id ?? null,
145
+ collectionType: collection?.type ?? COLLECTION_TYPE.SELF,
146
+ collectionId: collection?.id ?? COLLECTION_ID_SELF,
147
147
  resumeTimeSeconds: resumeTime ?? null,
148
148
  timestamp: Date.now()
149
149
  })
@@ -165,8 +165,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
165
165
  ProgressRepository.generateId(contentId, collection),
166
166
  (r: ContentProgress) => {
167
167
  r.content_id = contentId
168
- r.collection_type = collection?.type ?? null
169
- r.collection_id = collection?.id ?? null
168
+ r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
169
+ r.collection_id = collection?.id ?? COLLECTION_ID_SELF
170
170
 
171
171
  r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
172
172
  r.progress_percent = progressPct
@@ -185,8 +185,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
185
185
  ProgressRepository.generateId(+contentId, collection),
186
186
  (r: ContentProgress) => {
187
187
  r.content_id = +contentId
188
- r.collection_type = collection?.type ?? null
189
- r.collection_id = collection?.id ?? null
188
+ r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
189
+ r.collection_id = collection?.id ?? COLLECTION_ID_SELF
190
190
 
191
191
  r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
192
192
  r.progress_percent = progressPct
@@ -204,10 +204,6 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
204
204
  contentId: number,
205
205
  collection: { type: COLLECTION_TYPE; id: number } | null
206
206
  ) {
207
- if (collection) {
208
- return `${contentId}:${collection.type}:${collection.id}`
209
- } else {
210
- return `${contentId}`
211
- }
207
+ return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
212
208
  }
213
209
  }
@@ -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
+ }