musora-content-services 2.89.0 → 2.92.3

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 (139) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Forums.html +2 -2
  4. package/docs/Gamification.html +2 -2
  5. package/docs/TestUser.html +2 -2
  6. package/docs/UserManagementSystem.html +2 -2
  7. package/docs/api_types.js.html +2 -2
  8. package/docs/config.js.html +2 -2
  9. package/docs/content-org_content-org.js.html +2 -2
  10. package/docs/content-org_guided-courses.ts.html +2 -2
  11. package/docs/content-org_learning-paths.ts.html +52 -40
  12. package/docs/content-org_playlists-types.js.html +2 -2
  13. package/docs/content-org_playlists.js.html +2 -2
  14. package/docs/content.js.html +2 -2
  15. package/docs/content_artist.ts.html +2 -2
  16. package/docs/content_genre.ts.html +2 -2
  17. package/docs/content_instructor.ts.html +2 -2
  18. package/docs/forums_categories.ts.html +2 -2
  19. package/docs/forums_forums.ts.html +2 -2
  20. package/docs/forums_posts.ts.html +2 -2
  21. package/docs/forums_threads.ts.html +2 -2
  22. package/docs/gamification_awards.ts.html +2 -2
  23. package/docs/gamification_gamification.js.html +2 -2
  24. package/docs/global.html +2 -2
  25. package/docs/index.html +2 -2
  26. package/docs/liveTesting.ts.html +2 -2
  27. package/docs/module-Accounts.html +2 -2
  28. package/docs/module-Artist.html +2 -2
  29. package/docs/module-Awards.html +2 -2
  30. package/docs/module-Config.html +2 -2
  31. package/docs/module-Content-Services-V2.html +2 -2
  32. package/docs/module-Forums.html +2 -2
  33. package/docs/module-Genre.html +2 -2
  34. package/docs/module-GuidedCourses.html +2 -2
  35. package/docs/module-Instructor.html +2 -2
  36. package/docs/module-Interests.html +2 -2
  37. package/docs/module-LearningPaths.html +269 -143
  38. package/docs/module-Onboarding.html +3 -3
  39. package/docs/module-Payments.html +2 -2
  40. package/docs/module-Permissions.html +2 -2
  41. package/docs/module-Playlists.html +2 -2
  42. package/docs/module-ProgressRow.html +2 -2
  43. package/docs/module-Railcontent-Services.html +34 -893
  44. package/docs/module-Sanity-Services.html +2 -2
  45. package/docs/module-Sessions.html +2 -2
  46. package/docs/module-UserActivity.html +70 -116
  47. package/docs/module-UserChat.html +2 -2
  48. package/docs/module-UserManagement.html +2 -2
  49. package/docs/module-UserMemberships.html +2 -2
  50. package/docs/module-UserNotifications.html +2 -2
  51. package/docs/module-UserProfile.html +2 -2
  52. package/docs/progress-row_method-card.js.html +3 -2
  53. package/docs/railcontent.js.html +14 -137
  54. package/docs/sanity.js.html +2 -2
  55. package/docs/userActivity.js.html +85 -150
  56. package/docs/user_account.ts.html +2 -2
  57. package/docs/user_chat.js.html +2 -2
  58. package/docs/user_interests.js.html +2 -2
  59. package/docs/user_management.js.html +2 -2
  60. package/docs/user_memberships.ts.html +2 -2
  61. package/docs/user_notifications.js.html +2 -2
  62. package/docs/user_onboarding.ts.html +10 -6
  63. package/docs/user_payments.ts.html +2 -2
  64. package/docs/user_permissions.js.html +2 -2
  65. package/docs/user_profile.js.html +2 -2
  66. package/docs/user_sessions.js.html +2 -2
  67. package/docs/user_types.js.html +2 -2
  68. package/docs/user_user-management-system.js.html +2 -2
  69. package/package.json +11 -3
  70. package/src/contentTypeConfig.js +6 -0
  71. package/src/index.d.ts +7 -31
  72. package/src/index.js +10 -34
  73. package/src/services/content-org/learning-paths.ts +31 -0
  74. package/src/services/contentAggregator.js +2 -2
  75. package/src/services/contentLikes.js +6 -39
  76. package/src/services/contentProgress.js +181 -479
  77. package/src/services/dataContext.js +0 -2
  78. package/src/services/progress-row/method-card.js +1 -0
  79. package/src/services/railcontent.js +12 -135
  80. package/src/services/sentry/.indexignore +0 -0
  81. package/src/services/sentry/index.ts +23 -0
  82. package/src/services/sync/.indexignore +0 -0
  83. package/src/services/sync/adapters/factory.ts +26 -0
  84. package/src/services/sync/adapters/lokijs.ts +1 -0
  85. package/src/services/sync/adapters/sqlite.ts +1 -0
  86. package/src/services/sync/concurrency-safety.ts +4 -0
  87. package/src/services/sync/context/index.ts +43 -0
  88. package/src/services/sync/context/providers/base.ts +4 -0
  89. package/src/services/sync/context/providers/connectivity.ts +14 -0
  90. package/src/services/sync/context/providers/durability.ts +5 -0
  91. package/src/services/sync/context/providers/index.ts +5 -0
  92. package/src/services/sync/context/providers/session.ts +8 -0
  93. package/src/services/sync/context/providers/tabs.ts +18 -0
  94. package/src/services/sync/context/providers/visibility.ts +14 -0
  95. package/src/services/sync/database/factory.ts +10 -0
  96. package/src/services/sync/errors/boundary.ts +45 -0
  97. package/src/services/sync/errors/index.ts +49 -0
  98. package/src/services/sync/fetch.ts +310 -0
  99. package/src/services/sync/index.ts +80 -0
  100. package/src/services/sync/manager.ts +139 -0
  101. package/src/services/sync/models/Base.ts +47 -0
  102. package/src/services/sync/models/ContentLike.ts +16 -0
  103. package/src/services/sync/models/ContentProgress.ts +69 -0
  104. package/src/services/sync/models/Practice.ts +72 -0
  105. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  106. package/src/services/sync/models/index.ts +4 -0
  107. package/src/services/sync/repositories/base.ts +247 -0
  108. package/src/services/sync/repositories/content-likes.ts +26 -0
  109. package/src/services/sync/repositories/content-progress.ts +160 -0
  110. package/src/services/sync/repositories/index.ts +4 -0
  111. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  112. package/src/services/sync/repositories/practices.ts +52 -0
  113. package/src/services/sync/repository-proxy.ts +48 -0
  114. package/src/services/sync/resolver.ts +84 -0
  115. package/src/services/sync/retry.ts +88 -0
  116. package/src/services/sync/run-scope.ts +30 -0
  117. package/src/services/sync/schema/index.ts +66 -0
  118. package/src/services/sync/serializers/index.ts +2 -0
  119. package/src/services/sync/serializers/model.ts +32 -0
  120. package/src/services/sync/serializers/raw.ts +21 -0
  121. package/src/services/sync/store/index.ts +779 -0
  122. package/src/services/sync/store/push-coalescer.ts +57 -0
  123. package/src/services/sync/store-configs.ts +41 -0
  124. package/src/services/sync/strategies/base.ts +21 -0
  125. package/src/services/sync/strategies/index.ts +12 -0
  126. package/src/services/sync/strategies/initial.ts +11 -0
  127. package/src/services/sync/strategies/polling.ts +54 -0
  128. package/src/services/sync/telemetry/index.ts +140 -0
  129. package/src/services/sync/telemetry/sampling.ts +91 -0
  130. package/src/services/sync/utils/event-emitter.ts +24 -0
  131. package/src/services/sync/utils/index.ts +1 -0
  132. package/src/services/sync/utils/throttle.ts +93 -0
  133. package/src/services/sync/utils/timers.ts +9 -0
  134. package/src/services/userActivity.js +83 -148
  135. package/test/contentProgress.test.js +6 -39
  136. package/test/live/contentProgressLive.test.js +2 -31
  137. package/tools/generate-index.cjs +10 -4
  138. package/.claude/settings.local.json +0 -8
  139. package/babel.config.cjs +0 -3
@@ -0,0 +1,72 @@
1
+ import { SYNC_TABLES } from '../schema'
2
+ import BaseModel from './Base'
3
+
4
+ export default class Practice extends BaseModel<{
5
+ manual_id: string | null
6
+ content_id: number | null
7
+ date: string
8
+ auto: boolean
9
+ duration_seconds: number
10
+ title: string | null
11
+ thumbnail_url: string | null
12
+ category_id: number | null
13
+ instrument_id: number | null
14
+ }> {
15
+ static table = SYNC_TABLES.PRACTICES
16
+
17
+ get manual_id() {
18
+ return this._getRaw('manual_id') as string | null
19
+ }
20
+ get content_id() {
21
+ return this._getRaw('content_id') as number | null
22
+ }
23
+ get date() {
24
+ return this._getRaw('date') as string
25
+ }
26
+ get auto() {
27
+ return this._getRaw('auto') as boolean
28
+ }
29
+ get duration_seconds() {
30
+ return this._getRaw('duration_seconds') as number
31
+ }
32
+ get title() {
33
+ return this._getRaw('title') as string | null
34
+ }
35
+ get thumbnail_url() {
36
+ return this._getRaw('thumbnail_url') as string | null
37
+ }
38
+ get category_id() {
39
+ return this._getRaw('category_id') as number | null
40
+ }
41
+ get instrument_id() {
42
+ return this._getRaw('instrument_id') as number | null
43
+ }
44
+
45
+ set manual_id(value: string | null) {
46
+ this._setRaw('manual_id', value)
47
+ }
48
+ set content_id(value: number | null) {
49
+ this._setRaw('content_id', value)
50
+ }
51
+ set date(value: string) {
52
+ this._setRaw('date', value)
53
+ }
54
+ set auto(value: boolean) {
55
+ this._setRaw('auto', value)
56
+ }
57
+ set duration_seconds(value: number) {
58
+ this._setRaw('duration_seconds', value)
59
+ }
60
+ set title(value: string | null) {
61
+ this._setRaw('title', value)
62
+ }
63
+ set thumbnail_url(value: string | null) {
64
+ this._setRaw('thumbnail_url', value)
65
+ }
66
+ set category_id(value: number | null) {
67
+ this._setRaw('category_id', value)
68
+ }
69
+ set instrument_id(value: number | null) {
70
+ this._setRaw('instrument_id', value)
71
+ }
72
+ }
@@ -0,0 +1,23 @@
1
+ import { SYNC_TABLES } from '../schema'
2
+ import BaseModel from './Base'
3
+
4
+ export default class PracticeDayNote extends BaseModel<{
5
+ date: string
6
+ notes: string
7
+ }> {
8
+ static table = SYNC_TABLES.PRACTICE_DAY_NOTES
9
+
10
+ get date() {
11
+ return this._getRaw('date') as string
12
+ }
13
+ get notes() {
14
+ return this._getRaw('notes') as string
15
+ }
16
+
17
+ set date(value: string) {
18
+ this._setRaw('date', value)
19
+ }
20
+ set notes(value: string) {
21
+ this._setRaw('notes', value)
22
+ }
23
+ }
@@ -0,0 +1,4 @@
1
+ export { default as ContentLike } from './ContentLike'
2
+ export { default as ContentProgress } from './ContentProgress'
3
+ export { default as Practice } from './Practice'
4
+ export { default as PracticeDayNote } from './PracticeDayNote'
@@ -0,0 +1,247 @@
1
+ import SyncStore from '../store'
2
+ import SyncContext from '../context'
3
+ import BaseModel from '../models/Base'
4
+ import { RecordId } from '@nozbe/watermelondb'
5
+ import type { Span } from '../telemetry/index'
6
+
7
+ import { SyncError, SyncExistsDTO, SyncReadDTO, SyncReadData, SyncWriteDTO, SyncWriteIdData, SyncWriteRecordData, SyncRemoteWriteDTO} from '..'
8
+ import { SyncPushResponse } from '../fetch'
9
+
10
+ import { Q } from '@nozbe/watermelondb'
11
+ export { Q }
12
+
13
+ export default class SyncRepository<TModel extends BaseModel> {
14
+ context: SyncContext
15
+ store: SyncStore<TModel>
16
+
17
+ constructor(store: SyncStore<TModel>) {
18
+ this.context = store.context
19
+ this.store = store
20
+ }
21
+
22
+ protected async readOne(id: RecordId) {
23
+ return this._respondToRead(() => this.store.readOne(id))
24
+ }
25
+
26
+ protected async readSome(ids: RecordId[]) {
27
+ return this._respondToRead(() => this.store.readSome(ids))
28
+ }
29
+
30
+ protected async readAll() {
31
+ return this._respondToRead(() => this.store.readAll())
32
+ }
33
+
34
+ protected async queryOne(...args: Q.Clause[]) {
35
+ return this._respondToRead(() => this.store.queryOne(...args))
36
+ }
37
+
38
+ protected async queryOneId(...args: Q.Clause[]) {
39
+ return this._respondToRead(() => this.store.queryOneId(...args))
40
+ }
41
+
42
+ protected async queryAll(...args: Q.Clause[]) {
43
+ return this._respondToRead(() => this.store.queryAll(...args))
44
+ }
45
+
46
+ protected async queryAllIds(...args: Q.Clause[]) {
47
+ return this._respondToRead(() => this.store.queryAllIds(...args))
48
+ }
49
+
50
+ protected async fetchOne(id: RecordId) {
51
+ return this._fetch(() => this.store.readOne(id))
52
+ }
53
+
54
+ protected async fetchAll() {
55
+ return this._fetch(() => this.store.readAll())
56
+ }
57
+
58
+ protected async existOne(id: RecordId) {
59
+ const r = await this.readOne(id)
60
+ return {
61
+ ...r,
62
+ data: r.data !== null
63
+ }
64
+ }
65
+
66
+ protected async existSome(ids: RecordId[]) {
67
+ const read = await this.readSome(ids)
68
+ const map = new Map<RecordId, (typeof read.data)[0]>()
69
+ read.data.forEach((record) => map.set(record.id, record))
70
+
71
+ const result: SyncExistsDTO<TModel, boolean[]> = {
72
+ ...read,
73
+ data: ids.map((id) => map.has(id)),
74
+ }
75
+ return result
76
+ }
77
+
78
+ protected async insertOne(builder: (record: TModel) => void) {
79
+ return this.store.telemetry.trace(
80
+ { name: `insertOne:${this.store.model.table}`, op: 'insert' },
81
+ (span) => this._respondToWrite(() => this.store.insertOne(builder, span), span)
82
+ )
83
+ }
84
+
85
+ protected async updateOneId(id: RecordId, builder: (record: TModel) => void) {
86
+ return this.store.telemetry.trace(
87
+ { name: `updateOne:${this.store.model.table}`, op: 'update' },
88
+ (span) => this._respondToWrite(() => this.store.updateOneId(id, builder, span), span)
89
+ )
90
+ }
91
+
92
+ protected async upsertOneRemote(id: RecordId, builder: (record: TModel) => void) {
93
+ return this.store.telemetry.trace(
94
+ { name: `upsertOneRemote:${this.store.model.table}`, op: 'upsert' },
95
+ (span) => this._respondToRemoteWriteOne(() => this.store.upsertOneRemote(id, builder, span), id, span)
96
+ )
97
+ }
98
+
99
+ protected async upsertOne(id: RecordId, builder: (record: TModel) => void) {
100
+ return this.store.telemetry.trace(
101
+ { name: `upsertOne:${this.store.model.table}`, op: 'upsert' },
102
+ (span) => this._respondToWrite(() => this.store.upsertOne(id, builder, span), span)
103
+ )
104
+ }
105
+
106
+ protected async upsertOneTentative(id: RecordId, builder: (record: TModel) => void) {
107
+ return this.store.telemetry.trace(
108
+ { name: `upsertOneTentative:${this.store.model.table}`, op: 'upsert' },
109
+ (span) => this._respondToWrite(() => this.store.upsertOneTentative(id, builder, span), span)
110
+ )
111
+ }
112
+
113
+ protected async upsertSome(builders: Record<RecordId, (record: TModel) => void>) {
114
+ return this.store.telemetry.trace(
115
+ { name: `upsertSome:${this.store.model.table}`, op: 'upsert' },
116
+ (span) => this._respondToWrite(() => this.store.upsertSome(builders, span), span)
117
+ )
118
+ }
119
+
120
+ protected async upsertSomeTentative(builders: Record<RecordId, (record: TModel) => void>) {
121
+ return this.store.telemetry.trace(
122
+ { name: `upsertSomeTentative:${this.store.model.table}`, op: 'upsert' },
123
+ (span) => this._respondToWrite(() => this.store.upsertSomeTentative(builders, span), span)
124
+ )
125
+ }
126
+
127
+ protected async deleteOne(id: RecordId) {
128
+ return this.store.telemetry.trace(
129
+ { name: `delete:${this.store.model.table}`, op: 'delete' },
130
+ (span) => this._respondToWriteIds(() => this.store.deleteOne(id, span), span)
131
+ )
132
+ }
133
+
134
+ protected async deleteSome(ids: RecordId[]) {
135
+ return this.store.telemetry.trace(
136
+ { name: `deleteSome:${this.store.model.table}`, op: 'delete' },
137
+ (span) => this._respondToWriteIds(() => this.store.deleteSome(ids, span), span)
138
+ )
139
+ }
140
+
141
+ private async _respondToWrite<T extends SyncWriteRecordData<TModel>>(create: () => Promise<T>, span?: Span) {
142
+ const data = await create()
143
+
144
+ let response: SyncPushResponse | null = null
145
+ if (!this.context.durability.getValue()) {
146
+ response = await this.store.pushRecordIdsImpatiently('id' in data ? [data.id] : data.map(r => r.id), span)
147
+
148
+ if (!response.ok) {
149
+ throw new SyncError('Failed to push records', { response })
150
+ }
151
+ }
152
+
153
+ const ret: SyncWriteDTO<TModel, T> = {
154
+ data,
155
+ status: response ? 'synced' : 'unsynced',
156
+ pushStatus: response ? 'success' : 'pending',
157
+ }
158
+ return ret
159
+ }
160
+
161
+ private async _respondToWriteIds<T extends SyncWriteIdData<TModel>>(create: () => Promise<T>, span?: Span) {
162
+ const data = await create()
163
+
164
+ let response: SyncPushResponse | null = null
165
+ if (!this.context.durability.getValue()) {
166
+ response = await this.store.pushRecordIdsImpatiently(typeof data === 'string' ? [data] : data, span)
167
+
168
+ if (!response.ok) {
169
+ throw new SyncError('Failed to push records', { response })
170
+ }
171
+ }
172
+
173
+ const ret: SyncWriteDTO<TModel, T> = {
174
+ data,
175
+ status: response ? 'synced' : 'unsynced',
176
+ pushStatus: response ? 'success' : 'pending',
177
+ }
178
+ return ret
179
+ }
180
+
181
+ private async _respondToRemoteWriteOne<T extends SyncPushResponse>(push: () => Promise<T>, id: RecordId, span?: Span) {
182
+ const response = await push()
183
+
184
+ if (!response.ok) {
185
+ throw new SyncError('Failed to push records', { response })
186
+ }
187
+
188
+ const data = await this.store.readOne(id)
189
+
190
+ const ret: SyncRemoteWriteDTO<TModel> = {
191
+ data,
192
+ status: 'synced',
193
+ pushStatus: 'success'
194
+ }
195
+ return ret
196
+ }
197
+
198
+ // read from local db, but pull (and throw (!) if it fails) if it's never been synced before
199
+ private async _respondToRead<T extends SyncReadData<TModel>>(query: () => Promise<T>) {
200
+ const fetchToken = await this.store.getLastFetchToken()
201
+ const everPulled = !!fetchToken
202
+ let pull: Awaited<ReturnType<typeof this.store.pullRecords>> | null = null
203
+
204
+ if (!everPulled) {
205
+ pull = await this.store.pullRecords()
206
+ if (!pull.ok) {
207
+ throw new SyncError('Failed to pull records', { pull })
208
+ }
209
+ }
210
+
211
+ const data = await query()
212
+
213
+ const result: SyncReadDTO<TModel, T> = {
214
+ data,
215
+ status: pull?.ok ? 'fresh' : 'stale',
216
+ pullStatus: pull?.ok ? 'success' : 'failure',
217
+ lastFetchToken: fetchToken,
218
+ }
219
+ return result
220
+ }
221
+
222
+ private async _fetch<T extends SyncReadData<TModel>>(query: () => Promise<T>) {
223
+ const [response, fetchToken] = await Promise.all([
224
+ this.store.pullRecords(),
225
+ this.store.getLastFetchToken(),
226
+ ])
227
+ const data = await query()
228
+
229
+ if (!response.ok) {
230
+ const result: SyncReadDTO<TModel, T> = {
231
+ data,
232
+ status: 'stale',
233
+ pullStatus: 'failure',
234
+ lastFetchToken: fetchToken,
235
+ }
236
+ return result
237
+ }
238
+
239
+ const result: SyncReadDTO<TModel, T> = {
240
+ data,
241
+ status: 'fresh',
242
+ pullStatus: 'success',
243
+ lastFetchToken: response.token,
244
+ }
245
+ return result
246
+ }
247
+ }
@@ -0,0 +1,26 @@
1
+ import SyncRepository from "./base";
2
+ import ContentLike from "../models/ContentLike";
3
+
4
+ export default class LikesRepository extends SyncRepository<ContentLike> {
5
+ async isLiked(contentId: number) {
6
+ return await this.existOne(LikesRepository.generateId(contentId))
7
+ }
8
+
9
+ async areLiked(contentIds: number[]) {
10
+ return await this.existSome(contentIds.map(LikesRepository.generateId))
11
+ }
12
+
13
+ async like(contentId: number) {
14
+ return await this.upsertOne(LikesRepository.generateId(contentId), r => {
15
+ r.content_id = contentId;
16
+ })
17
+ }
18
+
19
+ async unlike(contentId: number) {
20
+ return await this.deleteOne(LikesRepository.generateId(contentId))
21
+ }
22
+
23
+ private static generateId(contentId: number) {
24
+ return contentId.toString();
25
+ }
26
+ }
@@ -0,0 +1,160 @@
1
+ import SyncRepository, { Q } from './base'
2
+ import ContentProgress, { COLLECTION_TYPE, STATE } from '../models/ContentProgress'
3
+
4
+ export default class ProgressRepository extends SyncRepository<ContentProgress> {
5
+ // null collection only
6
+ async startedIds(limit?: number) {
7
+ return this.queryAll(
8
+ Q.where('collection_type', null),
9
+ Q.where('collection_id', null),
10
+
11
+ Q.where('state', STATE.STARTED),
12
+ Q.sortBy('updated_at', 'desc'),
13
+ Q.take(limit || Infinity)
14
+ )
15
+ }
16
+
17
+ // null collection only
18
+ async completedIds(limit?: number) {
19
+ return this.queryAllIds(
20
+ Q.where('state', STATE.COMPLETED),
21
+ Q.sortBy('updated_at', 'desc'),
22
+ Q.take(limit || Infinity)
23
+ )
24
+ }
25
+
26
+ // null collection only
27
+ async startedOrCompleted(opts: Parameters<typeof this.startedOrCompletedClauses>[0] = {}) {
28
+ return this.queryAll(...this.startedOrCompletedClauses(opts))
29
+ }
30
+
31
+ // null collection only
32
+ async startedOrCompletedIds(opts: Parameters<typeof this.startedOrCompletedClauses>[0] = {}) {
33
+ return this.queryAllIds(...this.startedOrCompletedClauses(opts))
34
+ }
35
+
36
+ // null collection only
37
+ private startedOrCompletedClauses(
38
+ opts: {
39
+ brand?: string
40
+ updatedAfter?: number
41
+ limit?: number
42
+ } = {}
43
+ ) {
44
+ const clauses: Q.Clause[] = [
45
+ Q.where('collection_type', null),
46
+ Q.where('collection_id', null),
47
+
48
+ Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
49
+ Q.sortBy('updated_at', 'desc'),
50
+ ]
51
+
52
+ if (opts.updatedAfter) {
53
+ clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
54
+ }
55
+
56
+ if (opts.brand) {
57
+ clauses.push(Q.where('content_brand', opts.brand))
58
+ }
59
+
60
+ if (opts.limit) {
61
+ clauses.push(Q.take(opts.limit))
62
+ }
63
+
64
+ return clauses
65
+ }
66
+
67
+ async mostRecentlyUpdatedId(contentIds: number[], collection: { type: COLLECTION_TYPE; id: number } | null = null) {
68
+ return this.queryOneId(
69
+ Q.where('content_id', Q.oneOf(contentIds)),
70
+ Q.where('collection_type', collection?.type ?? null),
71
+ Q.where('collection_id', collection?.id ?? null),
72
+
73
+ Q.sortBy('updated_at', 'desc')
74
+ )
75
+ }
76
+
77
+ async getOneProgressByContentId(
78
+ contentId: number,
79
+ { collection }: { collection?: { type: COLLECTION_TYPE; id: number } | null } = {}
80
+ ) {
81
+ const clauses = [Q.where('content_id', contentId)]
82
+ if (typeof collection != 'undefined') {
83
+ clauses.push(
84
+ ...[
85
+ Q.where('collection_type', collection?.type ?? null),
86
+ Q.where('collection_id', collection?.id ?? null),
87
+ ]
88
+ )
89
+ }
90
+
91
+ return await this.queryOne(...clauses)
92
+ }
93
+
94
+ async getSomeProgressByContentIds(
95
+ contentIds: number[],
96
+ collection: { type: COLLECTION_TYPE; id: number } | null = null
97
+ ) {
98
+ const clauses = [Q.where('content_id', Q.oneOf(contentIds))]
99
+ if (typeof collection != 'undefined') {
100
+ clauses.push(
101
+ ...[
102
+ Q.where('collection_type', collection?.type ?? null),
103
+ Q.where('collection_id', collection?.id ?? null),
104
+ ]
105
+ )
106
+ }
107
+
108
+ return await this.queryAll(...clauses)
109
+ }
110
+
111
+ recordProgressRemotely(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null, progressPct: number, resumeTime?: number) {
112
+ const id = ProgressRepository.generateId(contentId, collection)
113
+
114
+ return this.upsertOneRemote(id, (r) => {
115
+ r.content_id = contentId
116
+ r.collection_type = collection?.type ?? null
117
+ r.collection_id = collection?.id ?? null
118
+
119
+ r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
120
+ r.progress_percent = progressPct
121
+
122
+ if (typeof resumeTime != 'undefined') {
123
+ r.resume_time_seconds = Math.floor(resumeTime)
124
+ }
125
+ })
126
+ }
127
+
128
+ recordProgressesTentative(contentProgresses: Map<number, number>, collection: { type: COLLECTION_TYPE; id: number } | null) {
129
+ return this.upsertSomeTentative(
130
+ Object.fromEntries(
131
+ Array.from(contentProgresses, ([contentId, progressPct]) => [
132
+ ProgressRepository.generateId(contentId, null),
133
+ (r) => {
134
+ r.content_id = contentId
135
+ r.collection_type = collection?.type ?? null
136
+ r.collection_id = collection?.id ?? null
137
+
138
+ r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
139
+ r.progress_percent = progressPct
140
+ },
141
+ ])
142
+ )
143
+ )
144
+ }
145
+
146
+ eraseProgress(contentId: number, collection: { type: COLLECTION_TYPE; id: number } | null) {
147
+ return this.deleteOne(ProgressRepository.generateId(contentId, collection))
148
+ }
149
+
150
+ private static generateId(
151
+ contentId: number,
152
+ collection: { type: COLLECTION_TYPE; id: number } | null
153
+ ) {
154
+ if (collection) {
155
+ return `${contentId}:${collection.type}:${collection.id}`
156
+ } else {
157
+ return `${contentId}`
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,4 @@
1
+ export { default as ContentLikesRepository } from './content-likes'
2
+ export { default as ContentProgressRepository } from './content-progress'
3
+ export { default as PracticesRepository } from './practices'
4
+ export { default as PracticeDayNotesRepository } from './practice-day-notes'
@@ -0,0 +1,4 @@
1
+ import SyncRepository from "./base";
2
+ import PracticeDayNote from "../models/PracticeDayNote";
3
+
4
+ export default class PracticeDayNotesRepository extends SyncRepository<PracticeDayNote> {}
@@ -0,0 +1,52 @@
1
+ import SyncRepository from "./base";
2
+ import Practice from "../models/Practice";
3
+ import { RecordId } from "@nozbe/watermelondb";
4
+
5
+ export default class PracticesRepository extends SyncRepository<Practice> {
6
+ async trackAutoPractice(contentId: number, date: string, incrementalDurationSeconds: number) {
7
+ return await this.upsertOne(PracticesRepository.generateAutoId(contentId, date), r => {
8
+ r._raw.id = PracticesRepository.generateAutoId(contentId, date);
9
+ r.auto = true;
10
+ r.content_id = contentId;
11
+ r.date = date;
12
+
13
+ r.duration_seconds = Math.min((r.duration_seconds || 0) + incrementalDurationSeconds, 59999);
14
+ })
15
+ }
16
+
17
+ async recordManualPractice(date: string, durationSeconds: number, details: Partial<Pick<Practice, 'title' | 'instrument_id' | 'category_id' | 'thumbnail_url'>> = {}) {
18
+ return await this.insertOne((r) => {
19
+ const manualId = r._raw.id; // yoink watermelon's autogenerated id
20
+ r._raw.id = PracticesRepository.generateManualId(manualId);
21
+
22
+ r.manual_id = manualId;
23
+ r.auto = false;
24
+
25
+ r.date = date;
26
+ r.duration_seconds = Math.min(durationSeconds, 59999);
27
+
28
+ r.title = details.title ?? null;
29
+ r.thumbnail_url = details.thumbnail_url ?? null;
30
+ r.category_id = details.category_id ?? null;
31
+ r.instrument_id = details.instrument_id ?? null;
32
+ });
33
+ }
34
+
35
+ async updateDetails(id: RecordId, details: Partial<Pick<Practice, 'duration_seconds' | 'title' | 'thumbnail_url' | 'category_id' | 'instrument_id'>>) {
36
+ return await this.updateOneId(id, r => {
37
+ r.duration_seconds = Math.min(details.duration_seconds, 59999) ?? r.duration_seconds;
38
+ r.title = details.title ?? r.title;
39
+ r.thumbnail_url = details.thumbnail_url ?? r.thumbnail_url;
40
+ r.category_id = details.category_id ?? r.category_id;
41
+ r.instrument_id = details.instrument_id ?? r.instrument_id;
42
+ })
43
+ }
44
+
45
+ private static generateAutoId(contentId: number, date: string) {
46
+ return ['auto', contentId.toString(), date].join(':');
47
+ }
48
+
49
+ private static generateManualId(manualId: string) {
50
+ return `manual:${manualId}`;
51
+ }
52
+ }
@@ -0,0 +1,48 @@
1
+ import SyncManager from "./manager"
2
+ import { SyncError } from "./errors"
3
+
4
+ import {
5
+ ContentLikesRepository,
6
+ ContentProgressRepository,
7
+ PracticesRepository,
8
+ PracticeDayNotesRepository
9
+ } from "./repositories"
10
+ import {
11
+ ContentLike,
12
+ ContentProgress,
13
+ Practice,
14
+ PracticeDayNote
15
+ } from "./models"
16
+
17
+ interface SyncRepositories {
18
+ likes: ContentLikesRepository
19
+ contentProgress: ContentProgressRepository
20
+ practices: PracticesRepository
21
+ practiceDayNotes: PracticeDayNotesRepository
22
+ }
23
+
24
+ export default new Proxy({} as SyncRepositories, {
25
+ get(target: SyncRepositories, prop: keyof SyncRepositories) {
26
+ if (!target[prop]) {
27
+ const manager = SyncManager.getInstance()
28
+
29
+ switch (prop) {
30
+ case 'likes':
31
+ target[prop] = new ContentLikesRepository(manager.getStore(ContentLike))
32
+ break
33
+ case 'contentProgress':
34
+ target[prop] = new ContentProgressRepository(manager.getStore(ContentProgress))
35
+ break
36
+ case 'practices':
37
+ target[prop] = new PracticesRepository(manager.getStore(Practice))
38
+ break
39
+ case 'practiceDayNotes':
40
+ target[prop] = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote))
41
+ break
42
+ default:
43
+ throw new SyncError(`Repository '${prop}' not found`)
44
+ }
45
+ }
46
+ return target[prop]
47
+ }
48
+ })