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