musora-content-services 2.94.4 → 2.94.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.94.5](https://github.com/railroadmedia/musora-content-services/compare/v2.94.4...v2.94.5) (2025-12-03)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * refactor fetchLearningPathProgressCheckLessons to exclude duplicates ([#615](https://github.com/railroadmedia/musora-content-services/issues/615)) ([0f0f51e](https://github.com/railroadmedia/musora-content-services/commit/0f0f51e2148a9e611bb3cbc9d103f12952e00056))
11
+ * watermelon fixes for content progress ([#614](https://github.com/railroadmedia/musora-content-services/issues/614)) ([2c2fa8e](https://github.com/railroadmedia/musora-content-services/commit/2c2fa8e68d5b3174ca62c8b9a900370947e44725))
12
+
5
13
  ### [2.94.4](https://github.com/railroadmedia/musora-content-services/compare/v2.94.3...v2.94.4) (2025-12-03)
6
14
 
7
15
  ### [2.94.3](https://github.com/railroadmedia/musora-content-services/compare/v2.94.2...v2.94.3) (2025-12-03)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.94.4",
3
+ "version": "2.94.5",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -244,11 +244,12 @@ export async function fetchLearningPathLessons(
244
244
  * including other learning paths and a la carte progress.
245
245
  *
246
246
  * @param {number[]} contentIds The array of content IDs within the learning path
247
- * @returns {Promise<number[]>} Array with completed content IDs
247
+ * @returns {Promise<number[]>} Array with completed content IDs
248
248
  */
249
249
  export async function fetchLearningPathProgressCheckLessons(contentIds: number[]): Promise<number[]> {
250
250
  let query = await getAllCompletedByIds(contentIds)
251
- return query.data.map(lesson => lesson.content_id)
251
+ let completedProgress = query.data.map(progress => progress.content_id)
252
+ return contentIds.filter(contentId => completedProgress.includes(contentId))
252
253
  }
253
254
 
254
255
  interface completeMethodIntroVideo {
@@ -13,14 +13,15 @@ export async function getProgressState(contentId) {
13
13
  }
14
14
 
15
15
  export async function getProgressStateByIds(contentIds, collection = null) {
16
- return getByIds(contentIds, collection, 'state', '')
16
+ return getByIds(normalizeContentIds(contentIds), normalizeCollection(collection), 'state', '')
17
17
  }
18
18
 
19
19
  export async function getResumeTimeSecondsByIds(contentIds, collection = null) {
20
- return getByIds(contentIds, collection, 'resume_time_seconds', 0)
20
+ return getByIds(normalizeContentIds(contentIds), normalizeCollection(collection), 'resume_time_seconds', 0)
21
21
  }
22
22
 
23
23
  export async function getNavigateTo(data, collection = null) {
24
+ collection = normalizeCollection(collection)
24
25
  let navigateToData = {}
25
26
 
26
27
  const twoDepthContentTypes = ['pack'] // not adding method because it has its own logic (with active path)
@@ -116,10 +117,13 @@ function buildNavigateTo(content, child = null, collection = null) {
116
117
  * @returns {Promise<number>}
117
118
  */
118
119
  export async function getLastInteractedOf(contentIds, collection = null) {
119
- return db.contentProgress.mostRecentlyUpdatedId(contentIds, collection).then(r => r.data ? parseInt(r.data) : undefined)
120
+ return db.contentProgress.mostRecentlyUpdatedId(normalizeContentIds(contentIds), normalizeCollection(collection)).then(r => r.data ? parseInt(r.data) : undefined)
120
121
  }
121
122
 
122
123
  export async function getProgressDataByIds(contentIds, collection) {
124
+ contentIds = normalizeContentIds(contentIds)
125
+ collection = normalizeCollection(collection)
126
+
123
127
  const progress = Object.fromEntries(contentIds.map(id => [id, {
124
128
  last_update: 0,
125
129
  progress: 0,
@@ -145,6 +149,8 @@ async function getById(contentId, dataKey, defaultValue) {
145
149
  }
146
150
 
147
151
  async function getByIds(contentIds, collection, dataKey, defaultValue) {
152
+ if (contentIds.length === 0) return {}
153
+
148
154
  const progress = Object.fromEntries(contentIds.map(id => [id, defaultValue]))
149
155
  await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then(r => {
150
156
  r.data.forEach(p => {
@@ -163,7 +169,7 @@ export async function getAllCompleted(limit = null) {
163
169
  }
164
170
 
165
171
  export async function getAllCompletedByIds(contentIds) {
166
- return db.contentProgress.completedByContentIds(contentIds)
172
+ return db.contentProgress.completedByContentIds(normalizeContentIds(contentIds))
167
173
  }
168
174
 
169
175
  export async function getAllStartedOrCompleted({
@@ -236,6 +242,9 @@ export async function recordWatchSession(
236
242
  instrumentId = null,
237
243
  categoryId = null
238
244
  ) {
245
+ contentId = normalizeContentId(contentId)
246
+ collection = normalizeCollection(collection)
247
+
239
248
  const [session] = await Promise.all([
240
249
  trackPractice(contentId, secondsPlayed, prevSession, { instrumentId, categoryId }),
241
250
  trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds),
@@ -253,7 +262,6 @@ async function trackPractice(contentId, secondsPlayed, prevSession, details = {}
253
262
  session.set(contentId, secondsPlayed)
254
263
 
255
264
  await trackUserPractice(contentId, secondsSinceLastUpdate, details)
256
-
257
265
  return session
258
266
  }
259
267
 
@@ -266,15 +274,15 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
266
274
  }
267
275
 
268
276
  export async function contentStatusCompleted(contentId, collection = null) {
269
- return setStartedOrCompletedStatus(contentId, collection, true)
277
+ return setStartedOrCompletedStatus(normalizeContentId(contentId), normalizeCollection(collection), true)
270
278
  }
271
279
 
272
280
  export async function contentsStatusCompleted(contentIds, collection = null) {
273
- return setStartedOrCompletedStatuses(contentIds, collection, true)
281
+ return setStartedOrCompletedStatuses(normalizeContentIds(contentIds), normalizeCollection(collection), true)
274
282
  }
275
283
 
276
284
  export async function contentStatusStarted(contentId, collection = null) {
277
- return setStartedOrCompletedStatus(contentId, collection, false)
285
+ return setStartedOrCompletedStatus(normalizeContentId(contentId), normalizeCollection(collection), false)
278
286
  }
279
287
  export async function contentStatusReset(contentId, collection = null) {
280
288
  return resetStatus(contentId, collection)
@@ -286,7 +294,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
286
294
  // note - previous implementation explicitly did not trickle progress to children here
287
295
  // (only to siblings/parents via le bubbles)
288
296
 
289
- const bubbledProgresses = bubbleProgress(await getHierarchy(contentId, collection), contentId, collection)
297
+ const bubbledProgresses = await bubbleProgress(await getHierarchy(contentId, collection), contentId, collection)
290
298
  await db.contentProgress.recordProgressesTentative(bubbledProgresses, collection)
291
299
 
292
300
  return response
@@ -294,9 +302,6 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
294
302
 
295
303
  async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
296
304
  const progress = isCompleted ? 100 : 0
297
- // we explicitly pessimistically await a remote push here
298
- // because awards may be generated (on server) on completion
299
- // which we would want to toast the user about *in band*
300
305
  const response = await db.contentProgress.recordProgress(contentId, collection, progress)
301
306
 
302
307
  const hierarchy = await getHierarchy(contentId, collection)
@@ -412,3 +417,30 @@ function getChildrenToDepth(parentId, hierarchy, depth = 1) {
412
417
  })
413
418
  return allChildrenIds
414
419
  }
420
+
421
+ function normalizeContentId(contentId) {
422
+ if (typeof contentId === 'string' && isNaN(+contentId)) {
423
+ throw new Error(`Invalid content id: ${contentId}`)
424
+ }
425
+ return typeof contentId === 'string' ? +contentId : contentId
426
+ }
427
+
428
+ function normalizeContentIds(contentIds) {
429
+ return contentIds.map(id => normalizeContentId(id))
430
+ }
431
+
432
+ function normalizeCollection(collection) {
433
+ if (!collection) return null
434
+
435
+ if (COLLECTION_TYPE.indexOf(collection.type) === -1) {
436
+ throw new Error(`Invalid collection type: ${collection.type}`)
437
+ }
438
+ if (typeof collection.id === 'string' && isNaN(+collection.id)) {
439
+ throw new Error(`Invalid collection id: ${collection.id}`)
440
+ }
441
+
442
+ return {
443
+ type: collection.type,
444
+ id: typeof collection.id === 'string' ? +collection.id : collection.id,
445
+ }
446
+ }
@@ -159,20 +159,17 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
159
159
  collection: { type: COLLECTION_TYPE; id: number } | null
160
160
  ) {
161
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
- })
162
+ Object.entries(contentProgresses).map(([contentId, progressPct]) => [
163
+ ProgressRepository.generateId(+contentId, collection),
164
+ (r: ContentProgress) => {
165
+ r.content_id = +contentId
166
+ r.collection_type = collection?.type ?? null
167
+ r.collection_id = collection?.id ?? null
168
+
169
+ r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
170
+ r.progress_percent = progressPct
171
+ },
172
+ ])
176
173
  )
177
174
  return this.upsertSomeTentative(data)
178
175
  }
@@ -241,7 +241,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
241
241
  const ids = Object.keys(builders)
242
242
 
243
243
  const records = await this.telemeterizedWrite(span, async writer => {
244
- const existing = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))
244
+ const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))))
245
245
  const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
246
246
 
247
247
  const destroyedBuilds = existing.filter(record => record._raw._status === 'deleted').map(record => {
@@ -298,8 +298,8 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
298
298
  return await this.runScope.abortable(async () => {
299
299
  let record: TModel | null = null
300
300
 
301
- await this.telemeterizedWrite(span, async () => {
302
- const existing = await this.queryMaybeDeletedRecords(Q.where('id', id)).then(
301
+ await this.telemeterizedWrite(span, async writer => {
302
+ const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', id))).then(
303
303
  (records) => records[0] || null
304
304
  )
305
305
 
@@ -347,7 +347,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
347
347
  await this.runScope.abortable(async () => {
348
348
  await this.telemeterizedWrite(undefined, async writer => {
349
349
  const ids = recordRaws.map(r => r.id)
350
- const existingMap = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))).then(records => {
350
+ const existingMap = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))).then(records => {
351
351
  return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
352
352
  })
353
353
 
@@ -405,7 +405,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
405
405
  async importDeletion(ids: RecordId[]) {
406
406
  await this.runScope.abortable(async () => {
407
407
  await this.telemeterizedWrite(undefined, async writer => {
408
- const existingMap = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))).then(records => {
408
+ const existingMap = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))).then(records => {
409
409
  return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
410
410
  })
411
411
 
@@ -576,32 +576,56 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
576
576
  .then((records) => records[0] as TModel | null)
577
577
  }
578
578
 
579
+ /**
580
+ * Query records including ones marked as deleted
581
+ * WatermelonDB by default excludes deleted records from queries
582
+ */
579
583
  private async queryMaybeDeletedRecords(...args: Q.Clause[]) {
580
- const serializedQuery = this.collection.query(...args).serialize()
581
- const adjustedQuery = {
582
- ...serializedQuery,
583
- description: {
584
- ...serializedQuery.description,
585
- where: serializedQuery.description.where.filter(
586
- (w) =>
587
- !(
588
- w.type === 'where' &&
589
- w.left === '_status' &&
590
- w.comparison &&
591
- w.comparison.operator === 'notEq' &&
592
- w.comparison.right &&
593
- 'value' in w.comparison.right &&
594
- w.comparison.right.value === 'deleted'
595
- )
596
- ),
597
- },
598
- }
584
+ return this.db.read(async () => {
585
+ const undeletedRecords = await this.collection.query(...args).fetch()
586
+
587
+ const serializedQuery = this.collection.query(...args).serialize()
588
+ const adjustedQuery = {
589
+ ...serializedQuery,
590
+ description: {
591
+ ...serializedQuery.description,
592
+ where: [
593
+ // remove the default "not deleted" clause added by WatermelonDB
594
+ ...serializedQuery.description.where.filter(
595
+ (w) =>
596
+ !(
597
+ w.type === 'where' &&
598
+ w.left === '_status' &&
599
+ w.comparison &&
600
+ w.comparison.operator === 'notEq' &&
601
+ w.comparison.right &&
602
+ 'value' in w.comparison.right &&
603
+ w.comparison.right.value === 'deleted'
604
+ )
605
+ ),
606
+
607
+ // and add our own "include deleted" clause
608
+ Q.where('_status', Q.eq('deleted'))
609
+ ],
610
+ },
611
+ }
599
612
 
600
- return (await this.db.adapter.unsafeQueryRaw(adjustedQuery)).map((raw) => {
601
- return new this.model(
602
- this.collection,
603
- sanitizedRaw(raw, this.db.schema.tables[this.collection.table])
604
- )
613
+ // NOTE: constructing models in this way is a bit of a hack,
614
+ // but since deleted records aren't "resurrectable" in WatermelonDB anyway,
615
+ // this should be fine for our use cases of mostly just reading _raw values
616
+ // **If you try to use this pattern to construct records that you intend
617
+ // to call `.update()` on, this won't work like you're expecting**
618
+ const deletedRecords = (await this.db.adapter.unsafeQueryRaw(adjustedQuery)).map((raw) => {
619
+ return new this.model(
620
+ this.collection,
621
+ sanitizedRaw(raw, this.db.schema.tables[this.collection.table])
622
+ )
623
+ })
624
+
625
+ return [
626
+ ...undeletedRecords,
627
+ ...deletedRecords,
628
+ ]
605
629
  })
606
630
  }
607
631
 
@@ -649,7 +673,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
649
673
  private async writeEntries(entries: SyncEntry[], freshSync: boolean = false, parentSpan?: Span) {
650
674
  await this.runScope.abortable(async () => {
651
675
  return this.telemeterizedWrite(parentSpan, async (writer) => {
652
- const batches = await this.buildWriteBatchesFromEntries(entries, freshSync)
676
+ const batches = await this.buildWriteBatchesFromEntries(writer, entries, freshSync)
653
677
 
654
678
  for (const batch of batches) {
655
679
  if (batch.length) {
@@ -660,10 +684,10 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
660
684
  })
661
685
  }
662
686
 
663
- private async buildWriteBatchesFromEntries(entries: SyncEntry[], freshSync: boolean) {
687
+ private async buildWriteBatchesFromEntries(writer: WriterInterface, entries: SyncEntry[], freshSync: boolean) {
664
688
  // if this is a fresh sync and there are no existing records, we can skip more sophisticated conflict resolution
665
689
  if (freshSync) {
666
- if ((await this.queryMaybeDeletedRecords()).length === 0) {
690
+ if ((await writer.callReader(() => this.queryMaybeDeletedRecords())).length === 0) {
667
691
  const resolver = new Resolver(this.resolverComparator)
668
692
  entries
669
693
  .filter((e) => !e.meta.lifecycle.deleted_at)
@@ -676,7 +700,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
676
700
  const entryIds = entries.map((e) => e.meta.ids.id)
677
701
  const existingRecordsMap = new Map<RecordId, TModel>()
678
702
 
679
- const existingRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(entryIds)))
703
+ const existingRecords = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(entryIds))))
680
704
  existingRecords.forEach((record) => existingRecordsMap.set(record.id, record))
681
705
 
682
706
  const resolver = new Resolver(this.resolverComparator)