musora-content-services 2.94.3 → 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 +10 -0
- package/package.json +1 -1
- package/src/services/content-org/learning-paths.ts +4 -9
- package/src/services/contentProgress.js +44 -12
- package/src/services/sync/repositories/content-progress.ts +11 -14
- package/src/services/sync/store/index.ts +57 -33
- package/.claude/settings.local.json +0 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
13
|
+
### [2.94.4](https://github.com/railroadmedia/musora-content-services/compare/v2.94.3...v2.94.4) (2025-12-03)
|
|
14
|
+
|
|
5
15
|
### [2.94.3](https://github.com/railroadmedia/musora-content-services/compare/v2.94.2...v2.94.3) (2025-12-03)
|
|
6
16
|
|
|
7
17
|
|
package/package.json
CHANGED
|
@@ -244,17 +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<
|
|
247
|
+
* @returns {Promise<number[]>} Array with completed content IDs
|
|
248
248
|
*/
|
|
249
|
-
export async function fetchLearningPathProgressCheckLessons(contentIds: number[]): Promise<
|
|
249
|
+
export async function fetchLearningPathProgressCheckLessons(contentIds: number[]): Promise<number[]> {
|
|
250
250
|
let query = await getAllCompletedByIds(contentIds)
|
|
251
|
-
let
|
|
252
|
-
|
|
253
|
-
return contentIds.reduce((obj, contentId) => {
|
|
254
|
-
let lessonIsCompleted = completedLessons.includes(contentId)
|
|
255
|
-
obj[contentId] = lessonIsCompleted ? STATE.COMPLETED : ""
|
|
256
|
-
return obj
|
|
257
|
-
}, {})
|
|
251
|
+
let completedProgress = query.data.map(progress => progress.content_id)
|
|
252
|
+
return contentIds.filter(contentId => completedProgress.includes(contentId))
|
|
258
253
|
}
|
|
259
254
|
|
|
260
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
w
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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)
|