musora-content-services 2.94.7 → 2.95.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.
Files changed (210) hide show
  1. package/.coderabbit.yaml +0 -0
  2. package/.editorconfig +0 -0
  3. package/.github/pull_request_template.md +0 -0
  4. package/.github/workflows/conventional-commits.yaml +0 -0
  5. package/.github/workflows/docs.js.yml +0 -0
  6. package/.github/workflows/node.js.yml +0 -0
  7. package/.prettierignore +0 -0
  8. package/.prettierrc +0 -0
  9. package/CHANGELOG.md +23 -0
  10. package/CLAUDE.md +408 -0
  11. package/README.md +0 -0
  12. package/babel.config.cjs +10 -0
  13. package/jest.config.js +0 -0
  14. package/jsdoc.json +2 -1
  15. package/package.json +2 -2
  16. package/src/constants/award-assets.js +35 -0
  17. package/src/contentMetaData.js +0 -0
  18. package/src/filterBuilder.js +7 -2
  19. package/src/index.d.ts +26 -5
  20. package/src/index.js +26 -5
  21. package/src/infrastructure/http/HttpClient.ts +0 -0
  22. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  23. package/src/infrastructure/http/index.ts +0 -0
  24. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  25. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  26. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  27. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  28. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  29. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  30. package/src/lib/brands.ts +0 -0
  31. package/src/lib/httpHelper.js +0 -0
  32. package/src/lib/lastUpdated.js +0 -0
  33. package/src/lib/sanity/query.ts +0 -0
  34. package/src/services/api/types.js +0 -0
  35. package/src/services/api/types.ts +0 -0
  36. package/src/services/awards/award-callbacks.js +126 -0
  37. package/src/services/awards/award-query.js +327 -0
  38. package/src/services/awards/internal/.indexignore +1 -0
  39. package/src/services/awards/internal/award-definitions.js +239 -0
  40. package/src/services/awards/internal/award-events.js +102 -0
  41. package/src/services/awards/internal/award-manager.js +162 -0
  42. package/src/services/awards/internal/certificate-builder.js +66 -0
  43. package/src/services/awards/internal/completion-data-generator.js +84 -0
  44. package/src/services/awards/internal/content-progress-observer.js +137 -0
  45. package/src/services/awards/internal/image-utils.js +62 -0
  46. package/src/services/awards/internal/message-generator.js +17 -0
  47. package/src/services/awards/internal/types.js +5 -0
  48. package/src/services/awards/types.d.ts +79 -0
  49. package/src/services/awards/types.js +101 -0
  50. package/src/services/config.js +24 -4
  51. package/src/services/content/artist.ts +0 -0
  52. package/src/services/content/content.ts +0 -0
  53. package/src/services/content/genre.ts +0 -0
  54. package/src/services/content/instructor.ts +0 -0
  55. package/src/services/content-org/content-org.js +0 -0
  56. package/src/services/content-org/guided-courses.ts +0 -0
  57. package/src/services/content-org/learning-paths.ts +19 -15
  58. package/src/services/content-org/playlists-types.js +0 -0
  59. package/src/services/content-org/playlists.js +0 -0
  60. package/src/services/content.js +0 -0
  61. package/src/services/contentAggregator.js +0 -0
  62. package/src/services/contentLikes.js +0 -0
  63. package/src/services/contentProgress.js +2 -1
  64. package/src/services/dataContext.js +0 -0
  65. package/src/services/dateUtils.js +0 -0
  66. package/src/services/eventsAPI.js +0 -0
  67. package/src/services/forums/forums.ts +0 -0
  68. package/src/services/forums/posts.ts +0 -0
  69. package/src/services/forums/threads.ts +0 -0
  70. package/src/services/forums/types.ts +0 -0
  71. package/src/services/gamification/awards.ts +114 -83
  72. package/src/services/gamification/gamification.js +0 -0
  73. package/src/services/imageSRCBuilder.js +0 -0
  74. package/src/services/imageSRCVerify.js +0 -0
  75. package/src/services/liveTesting.ts +0 -0
  76. package/src/services/permissions/PermissionsAdapter.ts +0 -0
  77. package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
  78. package/src/services/permissions/PermissionsV1Adapter.ts +0 -0
  79. package/src/services/permissions/PermissionsV2Adapter.ts +0 -0
  80. package/src/services/permissions/README.md +0 -0
  81. package/src/services/permissions/index.ts +0 -0
  82. package/src/services/progress-events.js +58 -0
  83. package/src/services/progress-row/method-card.js +20 -5
  84. package/src/services/railcontent.js +0 -0
  85. package/src/services/recommendations.js +0 -0
  86. package/src/services/reporting/README.md +0 -0
  87. package/src/services/reporting/reporting.ts +0 -0
  88. package/src/services/reporting/types.ts +0 -0
  89. package/src/services/sanity.js +1 -1
  90. package/src/services/sentry/.indexignore +0 -0
  91. package/src/services/sentry/index.ts +0 -0
  92. package/src/services/sync/.indexignore +0 -0
  93. package/src/services/sync/adapters/factory.ts +0 -0
  94. package/src/services/sync/adapters/lokijs.ts +0 -0
  95. package/src/services/sync/adapters/sqlite.ts +0 -0
  96. package/src/services/sync/concurrency-safety.ts +0 -0
  97. package/src/services/sync/context/index.ts +0 -0
  98. package/src/services/sync/context/providers/base.ts +0 -0
  99. package/src/services/sync/context/providers/connectivity.ts +0 -0
  100. package/src/services/sync/context/providers/durability.ts +0 -0
  101. package/src/services/sync/context/providers/index.ts +0 -0
  102. package/src/services/sync/context/providers/session.ts +0 -0
  103. package/src/services/sync/context/providers/tabs.ts +0 -0
  104. package/src/services/sync/context/providers/visibility.ts +0 -0
  105. package/src/services/sync/database/factory.ts +0 -0
  106. package/src/services/sync/errors/boundary.ts +0 -0
  107. package/src/services/sync/errors/index.ts +0 -0
  108. package/src/services/sync/fetch.ts +10 -2
  109. package/src/services/sync/index.ts +0 -0
  110. package/src/services/sync/manager.ts +6 -0
  111. package/src/services/sync/models/Base.ts +0 -0
  112. package/src/services/sync/models/ContentLike.ts +0 -0
  113. package/src/services/sync/models/ContentProgress.ts +5 -6
  114. package/src/services/sync/models/Practice.ts +0 -0
  115. package/src/services/sync/models/PracticeDayNote.ts +0 -0
  116. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  117. package/src/services/sync/models/index.ts +1 -0
  118. package/src/services/sync/repositories/base.ts +0 -0
  119. package/src/services/sync/repositories/content-likes.ts +0 -0
  120. package/src/services/sync/repositories/content-progress.ts +47 -25
  121. package/src/services/sync/repositories/index.ts +1 -0
  122. package/src/services/sync/repositories/practice-day-notes.ts +0 -0
  123. package/src/services/sync/repositories/practices.ts +16 -1
  124. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  125. package/src/services/sync/repository-proxy.ts +6 -0
  126. package/src/services/sync/resolver.ts +0 -0
  127. package/src/services/sync/retry.ts +12 -11
  128. package/src/services/sync/run-scope.ts +0 -0
  129. package/src/services/sync/schema/index.ts +18 -3
  130. package/src/services/sync/serializers/index.ts +0 -0
  131. package/src/services/sync/serializers/model.ts +0 -0
  132. package/src/services/sync/serializers/raw.ts +0 -0
  133. package/src/services/sync/store/index.ts +53 -8
  134. package/src/services/sync/store/push-coalescer.ts +3 -3
  135. package/src/services/sync/store-configs.ts +7 -1
  136. package/src/services/sync/strategies/base.ts +0 -0
  137. package/src/services/sync/strategies/index.ts +0 -0
  138. package/src/services/sync/strategies/initial.ts +0 -0
  139. package/src/services/sync/strategies/polling.ts +0 -0
  140. package/src/services/sync/telemetry/index.ts +0 -0
  141. package/src/services/sync/telemetry/sampling.ts +0 -0
  142. package/src/services/sync/utils/event-emitter.ts +0 -0
  143. package/src/services/sync/utils/index.ts +0 -0
  144. package/src/services/sync/utils/throttle.ts +0 -0
  145. package/src/services/sync/utils/timers.ts +0 -0
  146. package/src/services/types.js +0 -0
  147. package/src/services/user/account.ts +0 -0
  148. package/src/services/user/chat.js +0 -0
  149. package/src/services/user/interests.js +0 -0
  150. package/src/services/user/management.js +0 -0
  151. package/src/services/user/memberships.ts +0 -0
  152. package/src/services/user/notifications.js +0 -0
  153. package/src/services/user/onboarding.ts +0 -0
  154. package/src/services/user/payments.ts +0 -0
  155. package/src/services/user/permissions.js +0 -0
  156. package/src/services/user/profile.js +0 -0
  157. package/src/services/user/sessions.js +0 -0
  158. package/src/services/user/types.d.ts +0 -0
  159. package/src/services/user/types.js +0 -0
  160. package/src/services/user/user-management-system.js +0 -0
  161. package/src/services/userActivity.js +0 -1
  162. package/test/HttpClient.test.js +6 -6
  163. package/test/awards/award-alacarte-observer.test.js +196 -0
  164. package/test/awards/award-auto-refresh.test.js +83 -0
  165. package/test/awards/award-calculations.test.js +33 -0
  166. package/test/awards/award-certificate-display.test.js +328 -0
  167. package/test/awards/award-collection-edge-cases.test.js +210 -0
  168. package/test/awards/award-collection-filtering.test.js +285 -0
  169. package/test/awards/award-completion-flow.test.js +213 -0
  170. package/test/awards/award-exclusion-handling.test.js +273 -0
  171. package/test/awards/award-multi-lesson.test.js +241 -0
  172. package/test/awards/award-observer-integration.test.js +325 -0
  173. package/test/awards/award-query-messages.test.js +438 -0
  174. package/test/awards/award-user-collection.test.js +412 -0
  175. package/test/awards/duplicate-prevention.test.js +118 -0
  176. package/test/awards/helpers/completion-mock.js +54 -0
  177. package/test/awards/helpers/index.js +3 -0
  178. package/test/awards/helpers/mock-setup.js +69 -0
  179. package/test/awards/helpers/progress-emitter.js +39 -0
  180. package/test/awards/message-generator.test.js +162 -0
  181. package/test/content.test.js +0 -0
  182. package/test/contentLikes.test.js +0 -0
  183. package/test/contentProgress.test.js +0 -0
  184. package/test/dataContext.test.js +0 -0
  185. package/test/forum.test.js +0 -0
  186. package/test/imageSRCBuilder.test.js +0 -0
  187. package/test/imageSRCVerify.test.js +0 -0
  188. package/test/initializeTests.js +6 -0
  189. package/test/learningPaths.test.js +0 -0
  190. package/test/lib/lastUpdated.test.js +0 -0
  191. package/test/live/contentProgressLive.test.js +0 -0
  192. package/test/live/railcontentLive.test.js +0 -0
  193. package/test/localStorageMock.js +0 -0
  194. package/test/log.js +0 -0
  195. package/test/mockData/award-definitions.js +171 -0
  196. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  197. package/test/mockData/mockData_progress_content.json +0 -0
  198. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  199. package/test/mockData/mockData_user_practices.json +0 -0
  200. package/test/notifications.test.js +0 -0
  201. package/test/progressRows.test.js +0 -0
  202. package/test/sanityQueryService.test.js +0 -0
  203. package/test/streakMessage.test.js +0 -0
  204. package/test/sync/models/award-database-integration.test.js +519 -0
  205. package/test/user/permissions.test.js +0 -0
  206. package/test/userActivity.test.js +0 -0
  207. package/tools/generate-index.cjs +9 -0
  208. package/.claude/settings.local.json +0 -14
  209. package/.yarnrc.yml +0 -1
  210. package/test/reporting.test.js +0 -132
@@ -4,25 +4,31 @@ import ContentProgress, { COLLECTION_TYPE, STATE } from '../models/ContentProgre
4
4
  export default class ProgressRepository extends SyncRepository<ContentProgress> {
5
5
  // null collection only
6
6
  async startedIds(limit?: number) {
7
- return this.queryAll(
7
+ return this.queryAllIds(...[
8
8
  Q.where('collection_type', null),
9
9
  Q.where('collection_id', null),
10
10
 
11
11
  Q.where('state', STATE.STARTED),
12
12
  Q.sortBy('updated_at', 'desc'),
13
- Q.take(limit || Infinity)
14
- )
13
+
14
+ ...(limit ? [Q.take(limit)] : []),
15
+ ])
15
16
  }
16
17
 
17
18
  // null collection only
18
19
  async completedIds(limit?: number) {
19
- return this.queryAllIds(
20
+ return this.queryAllIds(...[
21
+ Q.where('collection_type', null),
22
+ Q.where('collection_id', null),
23
+
20
24
  Q.where('state', STATE.COMPLETED),
21
25
  Q.sortBy('updated_at', 'desc'),
22
- Q.take(limit || Infinity)
23
- )
26
+
27
+ ...(limit ? [Q.take(limit)] : []),
28
+ ])
24
29
  }
25
30
 
31
+ //this _specifically_ needs to get content_ids from ALL collection_types (including null)
26
32
  async completedByContentIds(contentIds: number[]) {
27
33
  return this.queryAll(
28
34
  Q.where('content_id', Q.oneOf(contentIds)),
@@ -85,15 +91,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
85
91
  contentId: number,
86
92
  { collection }: { collection?: { type: COLLECTION_TYPE; id: number } | null } = {}
87
93
  ) {
88
- const clauses = [Q.where('content_id', contentId)]
89
- if (typeof collection != 'undefined') {
90
- clauses.push(
91
- ...[
92
- Q.where('collection_type', collection?.type ?? null),
93
- Q.where('collection_id', collection?.id ?? null),
94
- ]
95
- )
96
- }
94
+ const clauses = [
95
+ Q.where('content_id', contentId),
96
+ Q.where('collection_type', collection?.type ?? null),
97
+ Q.where('collection_id', collection?.id ?? null),
98
+ ]
97
99
 
98
100
  return await this.queryOne(...clauses)
99
101
  }
@@ -102,15 +104,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
102
104
  contentIds: number[],
103
105
  collection: { type: COLLECTION_TYPE; id: number } | null = null
104
106
  ) {
105
- const clauses = [Q.where('content_id', Q.oneOf(contentIds))]
106
- if (typeof collection != 'undefined') {
107
- clauses.push(
108
- ...[
109
- Q.where('collection_type', collection?.type ?? null),
110
- Q.where('collection_id', collection?.id ?? null),
111
- ]
112
- )
113
- }
107
+ const clauses = [
108
+ Q.where('content_id', Q.oneOf(contentIds)),
109
+ Q.where('collection_type', collection?.type ?? null),
110
+ Q.where('collection_id', collection?.id ?? null),
111
+ ]
114
112
 
115
113
  return await this.queryAll(...clauses)
116
114
  }
@@ -118,7 +116,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
118
116
  recordProgress(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null, progressPct: number, resumeTime?: number) {
119
117
  const id = ProgressRepository.generateId(contentId, collection)
120
118
 
121
- return this.upsertOne(id, (r) => {
119
+ const result = this.upsertOne(id, (r) => {
122
120
  r.content_id = contentId
123
121
  r.collection_type = collection?.type ?? null
124
122
  r.collection_id = collection?.id ?? null
@@ -130,6 +128,30 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
130
128
  r.resume_time_seconds = Math.floor(resumeTime)
131
129
  }
132
130
  })
131
+
132
+ // Emit event AFTER database write completes
133
+ result.then(() => {
134
+ return Promise.all([
135
+ import('../../progress-events'),
136
+ import('../../config')
137
+ ])
138
+ }).then(([progressEventsModule, { globalConfig }]) => {
139
+ progressEventsModule.emitProgressSaved({
140
+ userId: Number(globalConfig.railcontentConfig?.userId) || 0,
141
+ contentId,
142
+ progressPercent: progressPct,
143
+ progressStatus: progressPct === 100 ? STATE.COMPLETED : STATE.STARTED,
144
+ bubble: true,
145
+ collectionType: collection?.type ?? null,
146
+ collectionId: collection?.id ?? null,
147
+ resumeTimeSeconds: resumeTime ?? null,
148
+ timestamp: Date.now()
149
+ })
150
+ }).catch(error => {
151
+ console.error('Failed to emit progress saved event:', error)
152
+ })
153
+
154
+ return result
133
155
  }
134
156
 
135
157
  recordProgresses(
@@ -1,4 +1,5 @@
1
1
  export { default as ContentLikesRepository } from './content-likes'
2
2
  export { default as ContentProgressRepository } from './content-progress'
3
3
  export { default as PracticesRepository } from './practices'
4
+ export { default as UserAwardProgressRepository } from './user-award-progress'
4
5
  export { default as PracticeDayNotesRepository } from './practice-day-notes'
@@ -1,8 +1,23 @@
1
- import SyncRepository from "./base";
1
+ import SyncRepository, { Q } from "./base";
2
2
  import Practice from "../models/Practice";
3
3
  import { RecordId } from "@nozbe/watermelondb";
4
4
 
5
5
  export default class PracticesRepository extends SyncRepository<Practice> {
6
+ async sumPracticeMinutesForContent(contentIds: number[]): Promise<number> {
7
+ if (contentIds.length === 0) return 0
8
+
9
+ const practices = await this.queryAll(
10
+ Q.where('content_id', Q.oneOf(contentIds))
11
+ )
12
+
13
+ const totalSeconds = practices.data.reduce(
14
+ (sum, practice) => sum + practice.duration_seconds,
15
+ 0
16
+ )
17
+
18
+ return Math.round(totalSeconds / 60)
19
+ }
20
+
6
21
  async trackAutoPractice(contentId: number, date: string, incrementalDurationSeconds: number) {
7
22
  return await this.upsertOne(PracticesRepository.generateAutoId(contentId, date), r => {
8
23
  r._raw.id = PracticesRepository.generateAutoId(contentId, date);
@@ -0,0 +1,133 @@
1
+ import { Q } from '@nozbe/watermelondb'
2
+ import UserAwardProgress from '../models/UserAwardProgress'
3
+ import SyncRepository from './base'
4
+ import type { AwardDefinition, CompletionData } from '../../awards/types'
5
+ import type { ModelSerialized } from '../serializers'
6
+
7
+ type AwardProgressData = {
8
+ completed_at: number | null
9
+ progress_percentage: number
10
+ }
11
+
12
+ export default class UserAwardProgressRepository extends SyncRepository<UserAwardProgress> {
13
+ static isCompleted(progress: AwardProgressData): boolean {
14
+ return progress.completed_at !== null && progress.progress_percentage === 100
15
+ }
16
+
17
+ static isInProgress(progress: AwardProgressData): boolean {
18
+ return progress.progress_percentage > 0 && !UserAwardProgressRepository.isCompleted(progress)
19
+ }
20
+
21
+ static completedAtDate(progress: { completed_at: number | null }): Date | null {
22
+ return progress.completed_at ? new Date(progress.completed_at) : null
23
+ }
24
+
25
+ async getAll(options?: {
26
+ limit?: number
27
+ onlyCompleted?: boolean
28
+ }) {
29
+ const clauses = []
30
+
31
+ if (options?.onlyCompleted) {
32
+ clauses.push(Q.where('completed_at', Q.notEq(null)))
33
+ }
34
+
35
+ clauses.push(Q.sortBy('updated_at', Q.desc))
36
+
37
+ if (options?.limit) {
38
+ clauses.push(Q.take(options.limit))
39
+ }
40
+
41
+ return this.queryAll(...clauses as any)
42
+ }
43
+
44
+ async getCompleted(limit?: number) {
45
+ return this.getAll({ onlyCompleted: true, limit })
46
+ }
47
+
48
+ async getInProgress(limit?: number) {
49
+ const clauses: any[] = [
50
+ Q.where('progress_percentage', Q.gt(0)),
51
+ Q.where('completed_at', Q.eq(null)),
52
+ Q.sortBy('progress_percentage', Q.desc)
53
+ ]
54
+
55
+ if (limit) {
56
+ clauses.push(Q.take(limit))
57
+ }
58
+
59
+ return this.queryAll(...clauses)
60
+ }
61
+
62
+ async getByAwardId(awardId: string) {
63
+ return this.readOne(awardId)
64
+ }
65
+
66
+ async hasCompletedAward(awardId: string): Promise<boolean> {
67
+ const result = await this.readOne(awardId)
68
+ if (!result.data) return false
69
+ return UserAwardProgressRepository.isCompleted(result.data)
70
+ }
71
+
72
+ async recordAwardProgress(
73
+ awardId: string,
74
+ progressPercentage: number,
75
+ options?: {
76
+ completedAt?: number | null
77
+ progressData?: any
78
+ completionData?: CompletionData | null
79
+ immediate?: boolean
80
+ }
81
+ ) {
82
+ const builder = (record: UserAwardProgress) => {
83
+ record.award_id = awardId
84
+ record.progress_percentage = progressPercentage
85
+
86
+ if (options?.completedAt !== undefined) {
87
+ record.completed_at = options.completedAt
88
+ }
89
+
90
+ if (options?.progressData !== undefined) {
91
+ record.progress_data = options.progressData
92
+ }
93
+
94
+ if (options?.completionData !== undefined) {
95
+ record.completion_data = options.completionData
96
+ }
97
+ }
98
+
99
+ return this.upsertOne(awardId, builder)
100
+ }
101
+
102
+ async completeAward(
103
+ awardId: string,
104
+ completionData: CompletionData
105
+ ) {
106
+ return this.recordAwardProgress(awardId, 100, {
107
+ completedAt: Date.now(),
108
+ completionData,
109
+ immediate: true
110
+ })
111
+ }
112
+
113
+ async getAwardsForContent(contentId: number): Promise<{
114
+ definitions: AwardDefinition[]
115
+ progress: Map<string, ModelSerialized<UserAwardProgress>>
116
+ }> {
117
+ const { awardDefinitions } = await import('../../awards/internal/award-definitions')
118
+
119
+ const definitions = await awardDefinitions.getByContentId(contentId)
120
+
121
+ const awardIds = definitions.map(d => d._id)
122
+ const progressMap = new Map<string, ModelSerialized<UserAwardProgress>>()
123
+
124
+ for (const awardId of awardIds) {
125
+ const result = await this.getByAwardId(awardId)
126
+ if (result.data) {
127
+ progressMap.set(awardId, result.data)
128
+ }
129
+ }
130
+
131
+ return { definitions, progress: progressMap }
132
+ }
133
+ }
@@ -7,10 +7,12 @@ import {
7
7
  PracticesRepository,
8
8
  PracticeDayNotesRepository
9
9
  } from "./repositories"
10
+ import UserAwardProgressRepository from "./repositories/user-award-progress"
10
11
  import {
11
12
  ContentLike,
12
13
  ContentProgress,
13
14
  Practice,
15
+ UserAwardProgress,
14
16
  PracticeDayNote
15
17
  } from "./models"
16
18
 
@@ -19,6 +21,7 @@ interface SyncRepositories {
19
21
  likes: ContentLikesRepository;
20
22
  contentProgress: ContentProgressRepository;
21
23
  practices: PracticesRepository;
24
+ userAwardProgress: UserAwardProgressRepository;
22
25
  practiceDayNotes: PracticeDayNotesRepository;
23
26
  }
24
27
 
@@ -47,6 +50,9 @@ const proxy = new Proxy({} as SyncRepositories, {
47
50
  case 'practices':
48
51
  cache.practices = new PracticesRepository(manager.getStore(Practice));
49
52
  break;
53
+ case 'userAwardProgress':
54
+ cache.userAwardProgress = new UserAwardProgressRepository(manager.getStore(UserAwardProgress));
55
+ break;
50
56
  case 'practiceDayNotes':
51
57
  cache.practiceDayNotes = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote));
52
58
  break;
File without changes
@@ -32,16 +32,10 @@ export default class SyncRetry {
32
32
  * Runs the given syncFn with automatic retries.
33
33
  * Returns the first successful result or the last failed result after retries.
34
34
  */
35
- async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T>) {
35
+ async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T | void>) {
36
36
  let attempt = 0
37
37
 
38
38
  while (true) {
39
- if (!this.context.connectivity.getValue()) {
40
- this.telemetry.debug('[Retry] No connectivity - skipping')
41
- this.paused = true
42
- return { ok: false } as T
43
- }
44
-
45
39
  const now = Date.now()
46
40
  if (now < this.backoffUntil) {
47
41
  await this.sleep(this.backoffUntil - now)
@@ -50,15 +44,22 @@ export default class SyncRetry {
50
44
  attempt++
51
45
 
52
46
  const spanOptions = { ...spanOpts, name: `${spanOpts.name}:attempt:${attempt}/${this.MAX_ATTEMPTS}`, op: `${spanOpts.op}:attempt` }
53
- const result = await this.telemetry.trace(spanOptions, span => syncFn(span))
47
+ const result = await this.telemetry.trace(spanOptions, span => {
48
+ if (!this.context.connectivity.getValue()) {
49
+ this.telemetry.debug('[Retry] No connectivity - skipping')
50
+ return { ok: false } as T
51
+ }
52
+
53
+ return syncFn(span)
54
+ })
55
+
56
+ if (!result) return result
54
57
 
55
58
  if (result.ok) {
56
59
  this.resetBackoff()
57
60
  return result
58
61
  } else {
59
- const isRetryable = 'isRetryable' in result ? result.isRetryable : false
60
-
61
- if (isRetryable) {
62
+ if (result.failureType === 'fetch' && result.isRetryable) {
62
63
  this.scheduleBackoff()
63
64
  if (attempt >= this.MAX_ATTEMPTS) return result
64
65
  } else {
File without changes
@@ -4,7 +4,8 @@ export const SYNC_TABLES = {
4
4
  CONTENT_LIKES: 'content_likes',
5
5
  CONTENT_PROGRESS: 'progress',
6
6
  PRACTICES: 'practices',
7
- PRACTICE_DAY_NOTES: 'practice_day_notes'
7
+ PRACTICE_DAY_NOTES: 'practice_day_notes',
8
+ USER_AWARD_PROGRESS: 'user_award_progress'
8
9
  }
9
10
 
10
11
  const contentLikesTable = tableSchema({
@@ -24,7 +25,7 @@ const contentProgressTable = tableSchema({
24
25
  { name: 'collection_id', type: 'number', isOptional: true, isIndexed: true },
25
26
  { name: 'state', type: 'string', isIndexed: true },
26
27
  { name: 'progress_percent', type: 'number' },
27
- { name: 'resume_time_seconds', type: 'number' },
28
+ { name: 'resume_time_seconds', type: 'number', isOptional: true },
28
29
  { name: 'created_at', type: 'number' },
29
30
  { name: 'updated_at', type: 'number', isIndexed: true }
30
31
  ]
@@ -55,12 +56,26 @@ const practiceDayNotesTable = tableSchema({
55
56
  ]
56
57
  })
57
58
 
59
+ const userAwardProgressTable = tableSchema({
60
+ name: SYNC_TABLES.USER_AWARD_PROGRESS,
61
+ columns: [
62
+ { name: 'award_id', type: 'string', isIndexed: true },
63
+ { name: 'progress_percentage', type: 'number' },
64
+ { name: 'completed_at', type: 'number', isOptional: true, isIndexed: true },
65
+ { name: 'progress_data', type: 'string', isOptional: true },
66
+ { name: 'completion_data', type: 'string', isOptional: true },
67
+ { name: 'created_at', type: 'number' },
68
+ { name: 'updated_at', type: 'number', isIndexed: true }
69
+ ]
70
+ })
71
+
58
72
  export default appSchema({
59
73
  version: 1,
60
74
  tables: [
61
75
  contentLikesTable,
62
76
  contentProgressTable,
63
77
  practicesTable,
64
- practiceDayNotesTable
78
+ practiceDayNotesTable,
79
+ userAwardProgressTable
65
80
  ]
66
81
  })
File without changes
File without changes
File without changes
@@ -1,7 +1,7 @@
1
1
  import { Database, Q, type Collection, type RecordId } from '@nozbe/watermelondb'
2
2
  import { RawSerializer, ModelSerializer } from '../serializers'
3
3
  import { ModelClass, SyncToken, SyncEntry, SyncContext, EpochMs } from '..'
4
- import { SyncPullResponse, SyncPushResponse, PushPayload } from '../fetch'
4
+ import { SyncPullResponse, SyncPushResponse, SyncPushFailureResponse, PushPayload } from '../fetch'
5
5
  import type SyncRetry from '../retry'
6
6
  import type SyncRunScope from '../run-scope'
7
7
  import EventEmitter from '../utils/event-emitter'
@@ -17,6 +17,7 @@ import { type WriterInterface } from '@nozbe/watermelondb/Database/WorkQueue'
17
17
  import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
18
18
  import { SyncError } from '../errors'
19
19
 
20
+
20
21
  type SyncPull = (
21
22
  session: BaseSessionProvider,
22
23
  previousFetchToken: SyncToken | null,
@@ -244,15 +245,39 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
244
245
  const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))))
245
246
  const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
246
247
 
247
- const destroyedBuilds = existing.filter(record => record._raw._status === 'deleted').map(record => {
248
- return new this.model(this.collection, { id: record.id }).prepareDestroyPermanently()
248
+ const destroyedBuilds = []
249
+ const recreateBuilds: Array<{ id: RecordId; created_at: EpochMs; builder: (record: TModel) => void }> = []
250
+
251
+ existing.forEach(record => {
252
+ if (record._raw._status === 'deleted') {
253
+ destroyedBuilds.push(new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
254
+ } else if (record._raw._status === 'created' && builders[record.id]) {
255
+ // Workaround for WatermelonDB bug: prepareUpdate() doesn't commit field changes
256
+ // for records with _status='created'. Destroy and recreate to ensure updates persist.
257
+ destroyedBuilds.push(new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
258
+ recreateBuilds.push({
259
+ id: record.id,
260
+ created_at: record._raw.created_at,
261
+ builder: builders[record.id]
262
+ })
263
+ }
249
264
  })
265
+
250
266
  const newBuilds = Object.entries(builders).map(([id, builder]) => {
251
267
  const existing = existingMap.get(id)
268
+ const recreate = recreateBuilds.find(r => r.id === id)
252
269
 
253
- if (existing && existing._raw._status !== 'deleted') {
270
+ if (recreate) {
271
+ return this.collection.prepareCreate(record => {
272
+ record._raw.id = id
273
+ record._raw.created_at = recreate.created_at
274
+ record._raw.updated_at = this.generateTimestamp()
275
+ record._raw._status = 'created'
276
+ builder(record)
277
+ })
278
+ } else if (existing && existing._raw._status !== 'deleted' && existing._raw._status !== 'created') {
254
279
  return existing.prepareUpdate(builder)
255
- } else {
280
+ } else if (!existing || existing._raw._status === 'deleted') {
256
281
  return this.collection.prepareCreate(record => {
257
282
  const now = this.generateTimestamp()
258
283
 
@@ -262,7 +287,8 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
262
287
  builder(record)
263
288
  })
264
289
  }
265
- })
290
+ return null
291
+ }).filter((build): build is ReturnType<typeof this.collection.prepareCreate> => build !== null)
266
292
 
267
293
  await writer.batch(...destroyedBuilds)
268
294
  await writer.batch(...newBuilds)
@@ -455,12 +481,26 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
455
481
  const records = await this.queryMaybeDeletedRecords(Q.where('_status', Q.notEq('synced')))
456
482
 
457
483
  if (records.length) {
484
+ const recordIds = records.map(r => r.id)
485
+ const updatedAtMap = new Map<RecordId, EpochMs>()
486
+ records.forEach(record => {
487
+ updatedAtMap.set(record.id, record._raw.updated_at)
488
+ })
489
+
458
490
  this.pushCoalescer.push(
459
491
  records,
460
492
  queueThrottle({ state: this.pushThrottleState }, () => {
461
- return this.retry.request(
493
+ return this.retry.request<SyncPushResponse>(
462
494
  { name: `push:${this.model.table}`, op: 'push', parentSpan: span },
463
- (span) => this.executePush(records, span)
495
+ async (span) => {
496
+ // re-query records since this fn may be deferred due to throttling/retries
497
+ const currentRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(recordIds)))
498
+ const recordsToPush = currentRecords.filter(r => r._raw.updated_at <= (updatedAtMap.get(r.id) || 0))
499
+
500
+ if (recordsToPush.length) {
501
+ return this.executePush(recordsToPush, span)
502
+ }
503
+ }
464
504
  )
465
505
  })
466
506
  )
@@ -468,6 +508,11 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
468
508
  }
469
509
 
470
510
  private async executePull(span?: Span) {
511
+ if (!this.context.connectivity.getValue()) {
512
+ this.telemetry.debug('[Retry] No connectivity - skipping')
513
+ return { ok: false } as SyncPushFailureResponse
514
+ }
515
+
471
516
  return this.telemetry.trace(
472
517
  {
473
518
  name: `pull:${this.model.table}:run`,
@@ -4,7 +4,7 @@ import { EpochMs } from ".."
4
4
  import { SyncPushResponse } from "../fetch"
5
5
 
6
6
  type PushIntent = {
7
- promise: Promise<SyncPushResponse>
7
+ promise: Promise<void | SyncPushResponse>
8
8
  records: {
9
9
  id: RecordId
10
10
  updatedAt: EpochMs
@@ -18,7 +18,7 @@ export default class PushCoalescer {
18
18
  this.intents = []
19
19
  }
20
20
 
21
- push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<SyncPushResponse>) {
21
+ push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<void | SyncPushResponse>) {
22
22
  const found = this.find(records)
23
23
 
24
24
  if (found) {
@@ -28,7 +28,7 @@ export default class PushCoalescer {
28
28
  return this.add(pusher(records), records)
29
29
  }
30
30
 
31
- private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
31
+ private add(promise: Promise<void | SyncPushResponse>, records: BaseModel[]) {
32
32
  const intent = {
33
33
  promise,
34
34
  records: records.map(record => ({
@@ -1,5 +1,5 @@
1
1
  import { SyncStoreConfig } from "./store"
2
- import { ContentLike, ContentProgress, Practice, PracticeDayNote } from "./models"
2
+ import { ContentLike, ContentProgress, Practice, UserAwardProgress, PracticeDayNote } from "./models"
3
3
  import { handlePull, handlePush, makeFetchRequest } from "./fetch"
4
4
 
5
5
  import type SyncStore from "./store"
@@ -36,6 +36,12 @@ export default function createStoresFromConfig(createStore: <TModel extends Base
36
36
  model: PracticeDayNote,
37
37
  pull: handlePull(makeFetchRequest('/api/user/practices/v1/notes')),
38
38
  push: handlePush(makeFetchRequest('/api/user/practices/v1/notes', { method: 'POST' })),
39
+ }),
40
+
41
+ createStore({
42
+ model: UserAwardProgress,
43
+ pull: handlePull(makeFetchRequest('/api/content/v1/user/awards')),
44
+ push: handlePush(makeFetchRequest('/api/content/v1/user/awards', { method: 'POST' })),
39
45
  })
40
46
  ] as unknown as SyncStore<BaseModel>[]
41
47
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -17,7 +17,6 @@ import {
17
17
  fetchShows,
18
18
  } from './sanity'
19
19
  import { fetchPlaylist, fetchUserPlaylists } from './content-org/playlists'
20
- import { guidedCourses } from './content-org/guided-courses'
21
20
  import {
22
21
  getMonday,
23
22
  getWeekNumber,