musora-content-services 2.90.0 → 2.92.6

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 (177) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +11 -3
  3. package/src/index.d.ts +9 -31
  4. package/src/index.js +12 -34
  5. package/src/services/content-org/learning-paths.ts +33 -3
  6. package/src/services/contentAggregator.js +2 -2
  7. package/src/services/contentLikes.js +6 -39
  8. package/src/services/contentProgress.js +181 -479
  9. package/src/services/dataContext.js +0 -2
  10. package/src/services/progress-row/method-card.js +2 -1
  11. package/src/services/railcontent.js +12 -135
  12. package/src/services/sentry/.indexignore +0 -0
  13. package/src/services/sentry/index.ts +23 -0
  14. package/src/services/sync/.indexignore +0 -0
  15. package/src/services/sync/adapters/factory.ts +26 -0
  16. package/src/services/sync/adapters/lokijs.ts +1 -0
  17. package/src/services/sync/adapters/sqlite.ts +1 -0
  18. package/src/services/sync/concurrency-safety.ts +4 -0
  19. package/src/services/sync/context/index.ts +43 -0
  20. package/src/services/sync/context/providers/base.ts +4 -0
  21. package/src/services/sync/context/providers/connectivity.ts +14 -0
  22. package/src/services/sync/context/providers/durability.ts +5 -0
  23. package/src/services/sync/context/providers/index.ts +5 -0
  24. package/src/services/sync/context/providers/session.ts +8 -0
  25. package/src/services/sync/context/providers/tabs.ts +18 -0
  26. package/src/services/sync/context/providers/visibility.ts +14 -0
  27. package/src/services/sync/database/factory.ts +10 -0
  28. package/src/services/sync/errors/boundary.ts +45 -0
  29. package/src/services/sync/errors/index.ts +49 -0
  30. package/src/services/sync/fetch.ts +313 -0
  31. package/src/services/sync/index.ts +80 -0
  32. package/src/services/sync/manager.ts +139 -0
  33. package/src/services/sync/models/Base.ts +47 -0
  34. package/src/services/sync/models/ContentLike.ts +16 -0
  35. package/src/services/sync/models/ContentProgress.ts +69 -0
  36. package/src/services/sync/models/Practice.ts +72 -0
  37. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  38. package/src/services/sync/models/index.ts +4 -0
  39. package/src/services/sync/repositories/base.ts +247 -0
  40. package/src/services/sync/repositories/content-likes.ts +26 -0
  41. package/src/services/sync/repositories/content-progress.ts +160 -0
  42. package/src/services/sync/repositories/index.ts +4 -0
  43. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  44. package/src/services/sync/repositories/practices.ts +52 -0
  45. package/src/services/sync/repository-proxy.ts +48 -0
  46. package/src/services/sync/resolver.ts +84 -0
  47. package/src/services/sync/retry.ts +88 -0
  48. package/src/services/sync/run-scope.ts +30 -0
  49. package/src/services/sync/schema/index.ts +66 -0
  50. package/src/services/sync/serializers/index.ts +2 -0
  51. package/src/services/sync/serializers/model.ts +32 -0
  52. package/src/services/sync/serializers/raw.ts +21 -0
  53. package/src/services/sync/store/index.ts +779 -0
  54. package/src/services/sync/store/push-coalescer.ts +57 -0
  55. package/src/services/sync/store-configs.ts +41 -0
  56. package/src/services/sync/strategies/base.ts +21 -0
  57. package/src/services/sync/strategies/index.ts +12 -0
  58. package/src/services/sync/strategies/initial.ts +11 -0
  59. package/src/services/sync/strategies/polling.ts +54 -0
  60. package/src/services/sync/telemetry/index.ts +140 -0
  61. package/src/services/sync/telemetry/sampling.ts +91 -0
  62. package/src/services/sync/utils/event-emitter.ts +24 -0
  63. package/src/services/sync/utils/index.ts +1 -0
  64. package/src/services/sync/utils/throttle.ts +93 -0
  65. package/src/services/sync/utils/timers.ts +9 -0
  66. package/src/services/userActivity.js +83 -148
  67. package/test/contentProgress.test.js +6 -39
  68. package/test/live/contentProgressLive.test.js +2 -31
  69. package/tools/generate-index.cjs +10 -4
  70. package/babel.config.cjs +0 -3
  71. package/docs/Content.html +0 -269
  72. package/docs/ContentOrganization.html +0 -245
  73. package/docs/Forums.html +0 -269
  74. package/docs/Gamification.html +0 -245
  75. package/docs/TestUser.html +0 -260
  76. package/docs/UserManagementSystem.html +0 -317
  77. package/docs/api_types.js.html +0 -97
  78. package/docs/config.js.html +0 -140
  79. package/docs/content-org_content-org.js.html +0 -76
  80. package/docs/content-org_guided-courses.ts.html +0 -110
  81. package/docs/content-org_learning-paths.ts.html +0 -379
  82. package/docs/content-org_playlists-types.js.html +0 -128
  83. package/docs/content-org_playlists.js.html +0 -440
  84. package/docs/content.js.html +0 -603
  85. package/docs/content_artist.ts.html +0 -206
  86. package/docs/content_content.ts.html +0 -77
  87. package/docs/content_genre.ts.html +0 -209
  88. package/docs/content_instructor.ts.html +0 -206
  89. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  90. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  91. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  92. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  93. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  94. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  95. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  96. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  97. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  98. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -978
  99. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  100. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  101. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  102. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  103. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -1049
  104. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  105. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  106. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  107. package/docs/forums_categories.ts.html +0 -156
  108. package/docs/forums_discussions.js.html +0 -95
  109. package/docs/forums_forum.js.html +0 -95
  110. package/docs/forums_forums.ts.html +0 -160
  111. package/docs/forums_posts.ts.html +0 -284
  112. package/docs/forums_threads.ts.html +0 -284
  113. package/docs/gamification_awards.js.html +0 -165
  114. package/docs/gamification_awards.ts.html +0 -195
  115. package/docs/gamification_gamification.js.html +0 -76
  116. package/docs/gamification_types.js.html +0 -80
  117. package/docs/global.html +0 -6019
  118. package/docs/index.html +0 -167
  119. package/docs/liveTesting.ts.html +0 -103
  120. package/docs/module-Accounts.html +0 -2283
  121. package/docs/module-Artist.html +0 -993
  122. package/docs/module-Awards.html +0 -836
  123. package/docs/module-Categories.html +0 -711
  124. package/docs/module-Config.html +0 -431
  125. package/docs/module-Content-Services-V2.html +0 -2998
  126. package/docs/module-ForumCategories.html +0 -687
  127. package/docs/module-ForumDiscussions.html +0 -370
  128. package/docs/module-Forums.html +0 -16599
  129. package/docs/module-Genre.html +0 -981
  130. package/docs/module-GuidedCourses.html +0 -108
  131. package/docs/module-Instructor.html +0 -929
  132. package/docs/module-Interests.html +0 -1066
  133. package/docs/module-LearningPaths.html +0 -2298
  134. package/docs/module-Onboarding.html +0 -882
  135. package/docs/module-Payments.html +0 -392
  136. package/docs/module-Permissions.html +0 -406
  137. package/docs/module-Playlists.html +0 -3030
  138. package/docs/module-ProgressRow.html +0 -108
  139. package/docs/module-Railcontent-Services.html +0 -6735
  140. package/docs/module-Sanity-Services.html +0 -8244
  141. package/docs/module-Sessions.html +0 -575
  142. package/docs/module-Threads.html +0 -1119
  143. package/docs/module-UserActivity.html +0 -4580
  144. package/docs/module-UserChat.html +0 -410
  145. package/docs/module-UserManagement.html +0 -1932
  146. package/docs/module-UserMemberships.html +0 -829
  147. package/docs/module-UserNotifications.html +0 -2595
  148. package/docs/module-UserProfile.html +0 -370
  149. package/docs/progress-row_method-card.js.html +0 -183
  150. package/docs/railcontent.js.html +0 -847
  151. package/docs/sanity.js.html +0 -2322
  152. package/docs/scripts/collapse.js +0 -39
  153. package/docs/scripts/commonNav.js +0 -28
  154. package/docs/scripts/linenumber.js +0 -25
  155. package/docs/scripts/nav.js +0 -12
  156. package/docs/scripts/polyfill.js +0 -4
  157. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
  158. package/docs/scripts/prettify/lang-css.js +0 -2
  159. package/docs/scripts/prettify/prettify.js +0 -28
  160. package/docs/scripts/search.js +0 -99
  161. package/docs/styles/jsdoc.css +0 -776
  162. package/docs/styles/prettify.css +0 -80
  163. package/docs/userActivity.js.html +0 -1577
  164. package/docs/user_account.ts.html +0 -265
  165. package/docs/user_chat.js.html +0 -98
  166. package/docs/user_interests.js.html +0 -150
  167. package/docs/user_management.js.html +0 -258
  168. package/docs/user_memberships.js.html +0 -144
  169. package/docs/user_memberships.ts.html +0 -292
  170. package/docs/user_notifications.js.html +0 -374
  171. package/docs/user_onboarding.ts.html +0 -325
  172. package/docs/user_payments.ts.html +0 -146
  173. package/docs/user_permissions.js.html +0 -110
  174. package/docs/user_profile.js.html +0 -115
  175. package/docs/user_sessions.js.html +0 -170
  176. package/docs/user_types.js.html +0 -224
  177. package/docs/user_user-management-system.js.html +0 -79
@@ -0,0 +1,313 @@
1
+ import { SyncToken, SyncEntry, SyncSyncable } from "./index"
2
+ import { EpochMs } from "."
3
+
4
+ import { globalConfig } from '../config.js'
5
+ import { RecordId } from "@nozbe/watermelondb"
6
+ import BaseModel from "./models/Base"
7
+ import { BaseSessionProvider } from "./context/providers"
8
+
9
+ interface RawPullResponse {
10
+ meta: {
11
+ since: EpochMs | null
12
+ max_updated_at: EpochMs | null
13
+ timestamp: EpochMs
14
+ }
15
+ entries: SyncEntry<BaseModel, 'client_record_id'>[]
16
+ }
17
+
18
+ interface RawPushResponse {
19
+ meta: {
20
+ timestamp: EpochMs
21
+ }
22
+ results: SyncStorePushResult<'client_record_id'>[]
23
+ }
24
+
25
+ export type SyncResponse = SyncPushResponse | SyncPullResponse
26
+
27
+ export type SyncPushResponse = SyncPushSuccessResponse | SyncPushFailureResponse
28
+
29
+ type SyncPushSuccessResponse = SyncPushResponseBase & {
30
+ ok: true
31
+ results: SyncStorePushResult[]
32
+ }
33
+ type SyncPushFailureResponse = SyncPushResponseBase & {
34
+ ok: false,
35
+ originalError: Error
36
+ }
37
+ interface SyncPushResponseBase extends SyncResponseBase {
38
+
39
+ }
40
+
41
+ type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
42
+ type SyncStorePushResultSuccess<TRecordKey extends string = 'id'> = SyncStorePushResultBase & {
43
+ type: 'success'
44
+ entry: SyncEntry<BaseModel, TRecordKey>
45
+ }
46
+ type SyncStorePushResultFailure<TRecordKey extends string = 'id'> = SyncStorePushResultProcessingFailure<TRecordKey> | SyncStorePushResultValidationFailure<TRecordKey>
47
+ type SyncStorePushResultProcessingFailure<TRecordKey extends string = 'id'> = SyncStorePushResultFailureBase<TRecordKey> & {
48
+ failureType: 'processing'
49
+ error: any
50
+ }
51
+ type SyncStorePushResultValidationFailure<TRecordKey extends string = 'id'> = SyncStorePushResultFailureBase<TRecordKey> & {
52
+ failureType: 'validation'
53
+ errors: Record<string, string[]>
54
+ }
55
+ interface SyncStorePushResultFailureBase<TRecordKey extends string = 'id'> extends SyncStorePushResultBase {
56
+ type: 'failure'
57
+ failureType: string
58
+ ids: { [K in TRecordKey]: RecordId }
59
+ }
60
+ interface SyncStorePushResultBase {
61
+ type: 'success' | 'failure'
62
+ }
63
+
64
+ export type SyncPullResponse = SyncPullSuccessResponse | SyncPullFailureResponse
65
+
66
+ type SyncPullSuccessResponse = SyncPullResponseBase & {
67
+ ok: true
68
+ entries: SyncEntry[]
69
+ token: SyncToken
70
+ previousToken: SyncToken | null
71
+ }
72
+ type SyncPullFailureResponse = SyncPullResponseBase & {
73
+ ok: false,
74
+ originalError: Error
75
+ }
76
+ interface SyncPullResponseBase extends SyncResponseBase {
77
+
78
+ }
79
+ export interface SyncResponseBase {
80
+ ok: boolean
81
+ }
82
+
83
+ export type PushPayload = {
84
+ entries: ({
85
+ record: SyncSyncable
86
+ meta: {
87
+ ids: {
88
+ id: RecordId
89
+ }
90
+ deleted: false
91
+ }
92
+ } | {
93
+ record: null
94
+ meta: {
95
+ ids: {
96
+ id: RecordId
97
+ }
98
+ deleted: true
99
+ }
100
+ })[]
101
+ }
102
+
103
+ interface ServerPushPayload {
104
+ entries: {
105
+ record: SyncSyncable<BaseModel, 'client_record_id'> | null
106
+ meta: {
107
+ ids: {
108
+ client_record_id: RecordId
109
+ },
110
+ deleted: boolean
111
+ }
112
+ }[]
113
+ }
114
+
115
+ export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (session: BaseSessionProvider) => Request {
116
+ return (session) => new Request(globalConfig.baseUrl + input, {
117
+ ...init,
118
+ headers: {
119
+ ...init?.headers,
120
+ ...globalConfig.sessionConfig?.token ? {
121
+ 'Authorization': `Bearer ${globalConfig.sessionConfig.token}`
122
+ } : {},
123
+ 'Content-Type': 'application/json',
124
+ 'X-Sync-Client-Id': session.getClientId(),
125
+ ...(session.getSessionId() ? {
126
+ 'X-Sync-Client-Session-Id': session.getSessionId()!
127
+ } : {})
128
+ }
129
+ })
130
+ }
131
+
132
+ export function handlePull(callback: (session: BaseSessionProvider) => Request) {
133
+ return async function(session: BaseSessionProvider, lastFetchToken: SyncToken | null, signal?: AbortSignal): Promise<SyncPullResponse> {
134
+ const generatedRequest = callback(session)
135
+ const url = serializePullUrlQuery(generatedRequest.url, lastFetchToken)
136
+ const request = new Request(url, {
137
+ credentials: 'include',
138
+ headers: generatedRequest.headers,
139
+ signal
140
+ });
141
+
142
+ let response: Response | null = null
143
+ try {
144
+ response = await performFetch(request)
145
+ } catch (e) {
146
+ return {
147
+ ok: false,
148
+ originalError: e
149
+ }
150
+ }
151
+
152
+ const json = await response.json() as RawPullResponse
153
+ const data = deserializePullResponse(json)
154
+
155
+ // if no max_updated_at, at least use the server's timestamp
156
+ // useful for recording that we have at least tried fetching even though resultset empty
157
+ const token = data.meta.max_updated_at || data.meta.timestamp
158
+ const previousToken = data.meta.since
159
+
160
+ const entries = data.entries
161
+
162
+ return {
163
+ ok: true,
164
+ entries,
165
+ token,
166
+ previousToken
167
+ }
168
+ }
169
+ }
170
+
171
+ export function handlePush(callback: (session: BaseSessionProvider) => Request) {
172
+ return async function(session: BaseSessionProvider, payload: PushPayload, signal?: AbortSignal): Promise<SyncPushResponse> {
173
+ const generatedRequest = callback(session)
174
+ const serverPayload = serializePushPayload(payload)
175
+ const request = new Request(generatedRequest, {
176
+ credentials: 'include',
177
+ body: JSON.stringify(serverPayload),
178
+ signal
179
+ })
180
+
181
+ let response: Response | null = null
182
+ try {
183
+ response = await performFetch(request)
184
+ } catch (e) {
185
+ return {
186
+ ok: false,
187
+ originalError: e
188
+ }
189
+ }
190
+
191
+ const json = await response.json() as RawPushResponse
192
+ const data = deserializePushResponse(json)
193
+
194
+ return {
195
+ ok: true,
196
+ results: data.results
197
+ }
198
+ }
199
+ }
200
+
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
+ function serializePullUrlQuery(url: string, fetchToken: SyncToken | null) {
213
+ const queryString = url.replace(/^[^?]*\??/, '');
214
+ const searchParams = new URLSearchParams(queryString);
215
+ if (fetchToken) {
216
+ searchParams.set('since', fetchToken.toString())
217
+ }
218
+ return url + '?' + searchParams.toString()
219
+ }
220
+
221
+ function deserializePullResponse(response: RawPullResponse) {
222
+ return {
223
+ ...response,
224
+ entries: response.entries.map(entry => {
225
+ return {
226
+ ...entry,
227
+ record: deserializeRecord(entry.record),
228
+ meta: {
229
+ ...entry.meta,
230
+ ids: deserializeIds(entry.meta.ids)
231
+ }
232
+ }
233
+ })
234
+ }
235
+ }
236
+
237
+ function serializePushPayload(payload: PushPayload): ServerPushPayload {
238
+ return {
239
+ ...payload,
240
+ entries: payload.entries.map(entry => {
241
+ return {
242
+ record: serializeRecord(entry.record),
243
+ meta: {
244
+ ...entry.meta,
245
+ ids: serializeIds(entry.meta.ids)
246
+ }
247
+ }
248
+ })
249
+ }
250
+ }
251
+
252
+ function deserializePushResponse(response: RawPushResponse) {
253
+ return {
254
+ results: response.results.map(result => {
255
+ if (result.type === 'success') {
256
+ const entry = result.entry
257
+
258
+ return {
259
+ ...result,
260
+ entry: {
261
+ ...entry,
262
+ record: deserializeRecord(entry.record),
263
+ meta: {
264
+ ...entry.meta,
265
+ ids: deserializeIds(entry.meta.ids)
266
+ }
267
+ }
268
+ }
269
+ } else {
270
+ return {
271
+ ...result,
272
+ ids: deserializeIds(result.ids)
273
+ }
274
+ }
275
+ })
276
+ }
277
+ }
278
+
279
+ function serializeRecord(record: SyncSyncable<BaseModel, 'id'> | null): SyncSyncable<BaseModel, 'client_record_id'> | null {
280
+ if (record) {
281
+ const { id, ...rest } = record
282
+ return {
283
+ ...rest,
284
+ client_record_id: id
285
+ }
286
+ }
287
+
288
+ return null
289
+ }
290
+
291
+ function serializeIds(ids: { id: RecordId }): { client_record_id: RecordId } {
292
+ return {
293
+ client_record_id: ids.id
294
+ }
295
+ }
296
+
297
+ function deserializeRecord(record: SyncSyncable<BaseModel, 'client_record_id'> | null): SyncSyncable<BaseModel, 'id'> | null {
298
+ if (record) {
299
+ const { client_record_id: id, ...rest } = record
300
+ return {
301
+ ...rest,
302
+ id
303
+ }
304
+ }
305
+
306
+ return null
307
+ }
308
+
309
+ function deserializeIds(ids: { client_record_id: RecordId }): { id: RecordId } {
310
+ return {
311
+ id: ids.client_record_id
312
+ }
313
+ }
@@ -0,0 +1,80 @@
1
+ import './telemetry/index'
2
+
3
+ import { Q, RecordId } from "@nozbe/watermelondb"
4
+ import { type ModelSerialized, type RawSerialized } from "./serializers"
5
+ import BaseModel from "./models/Base"
6
+
7
+ export { default as db } from './repository-proxy'
8
+ export { Q }
9
+
10
+ export { default as SyncSession } from './run-scope'
11
+ export { default as SyncRetry } from './retry'
12
+ export { default as SyncManager } from './manager'
13
+ export { default as SyncContext } from './context'
14
+ export { SyncError } from './errors'
15
+
16
+ type Branded<T, B extends string> = T & { __brand: B }
17
+ export type EpochMs = Branded<number, 'EpochMs'>
18
+ export type SyncToken = EpochMs
19
+
20
+ export type SyncSyncable<TModel extends BaseModel = BaseModel, TRecordKey extends string = 'id'> = {
21
+ [K in TRecordKey]: RecordId
22
+ } & Omit<RawSerialized<TModel>, 'id'>
23
+
24
+ export type SyncEntry<TModel extends BaseModel = BaseModel, TRecordKey extends string = 'id'> = {
25
+ record: SyncSyncable<TModel, TRecordKey> | null
26
+ meta: {
27
+ ids: { [K in TRecordKey]: RecordId }
28
+ lifecycle: {
29
+ created_at: EpochMs
30
+ updated_at: EpochMs
31
+ deleted_at: EpochMs | null
32
+ }
33
+ }
34
+ }
35
+ export type SyncEntryNonDeleted<TModel extends BaseModel = BaseModel, TRecordKey extends string = 'id'> = {
36
+ record: SyncSyncable<TModel, TRecordKey>
37
+ meta: {
38
+ ids: { [K in TRecordKey]: RecordId }
39
+ lifecycle: {
40
+ created_at: EpochMs
41
+ updated_at: EpochMs
42
+ deleted_at: null
43
+ }
44
+ }
45
+ }
46
+
47
+ export type SyncExistsDTO<_TModel extends BaseModel, T extends boolean | boolean[]> = {
48
+ data: T
49
+ status: 'fresh' | 'stale'
50
+ pullStatus: 'success' | 'pending' | 'failure' | null
51
+ lastFetchToken: SyncToken | null
52
+ }
53
+
54
+ export type SyncReadData<T extends BaseModel> = ModelSerialized<T> | ModelSerialized<T>[] | RecordId | RecordId[] | null
55
+ export type SyncReadDTO<T extends BaseModel, TData extends SyncReadData<T>> = {
56
+ data: TData
57
+ status: 'fresh' | 'stale'
58
+ pullStatus: 'success' | 'pending' | 'failure' | null
59
+ lastFetchToken: SyncToken | null
60
+ }
61
+
62
+ export type SyncWriteData<T extends BaseModel> = SyncWriteRecordData<T> | SyncWriteIdData<T>
63
+ export type SyncWriteRecordData<T extends BaseModel> = ModelSerialized<T> | ModelSerialized<T>[]
64
+ export type SyncWriteIdData<_T extends BaseModel> = RecordId | RecordId[]
65
+ export type SyncWriteDTO<T extends BaseModel, TData extends SyncWriteData<T>> = {
66
+ data: TData
67
+ status: 'synced' | 'unsynced'
68
+ pushStatus: 'success' | 'pending' | 'failure'
69
+ }
70
+
71
+ export type SyncRemoteWriteDTO<T extends BaseModel> = {
72
+ data: ModelSerialized<T> | null
73
+ status: 'synced'
74
+ pushStatus: 'success'
75
+ }
76
+
77
+ export type ModelClass<T extends BaseModel = BaseModel> = {
78
+ new (...args: any[]): T
79
+ table: string
80
+ }
@@ -0,0 +1,139 @@
1
+ import BaseModel from './models/Base'
2
+ import { Database } from '@nozbe/watermelondb'
3
+ import SyncRunScope from './run-scope'
4
+ import { SyncStrategy } from './strategies'
5
+ import { default as SyncStore, SyncStoreConfig } from './store'
6
+
7
+ import { ModelClass } from './index'
8
+ import SyncRetry from './retry'
9
+ import SyncContext from './context'
10
+ import { SyncError } from './errors'
11
+ import { SyncConcurrencySafetyMechanism } from './concurrency-safety'
12
+ import { SyncTelemetry } from './telemetry/index'
13
+ import { inBoundary } from './errors/boundary'
14
+ import createStoresFromConfig from './store-configs'
15
+
16
+ export default class SyncManager {
17
+ private static instance: SyncManager | null = null
18
+
19
+ public static assignAndSetupInstance(instance: SyncManager) {
20
+ if (SyncManager.instance) {
21
+ throw new SyncError('SyncManager already initialized')
22
+ }
23
+ SyncManager.instance = instance
24
+ const teardown = instance.setup()
25
+ return async () => {
26
+ await teardown()
27
+ SyncManager.instance = null
28
+ }
29
+ }
30
+
31
+ public static getInstance(): SyncManager {
32
+ if (!SyncManager.instance) {
33
+ throw new SyncError('SyncManager not initialized')
34
+ }
35
+ return SyncManager.instance
36
+ }
37
+
38
+ public telemetry: SyncTelemetry
39
+ private database: Database
40
+ private context: SyncContext
41
+ private storesRegistry: Record<string, SyncStore<any>>
42
+ private runScope: SyncRunScope
43
+ private retry: SyncRetry
44
+ private strategyMap: { stores: SyncStore<any>[]; strategies: SyncStrategy[] }[]
45
+ private safetyMap: { stores: SyncStore<any>[]; mechanisms: (() => void)[] }[]
46
+
47
+ constructor(context: SyncContext, initDatabase: () => Database) {
48
+ this.telemetry = SyncTelemetry.getInstance()!
49
+ this.context = context
50
+
51
+ this.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase))
52
+
53
+ this.runScope = new SyncRunScope()
54
+ this.retry = new SyncRetry(this.context, this.telemetry)
55
+
56
+ this.storesRegistry = this.registerStores(createStoresFromConfig(this.createStore.bind(this)))
57
+
58
+ this.strategyMap = []
59
+ this.safetyMap = []
60
+ }
61
+
62
+ createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
63
+ return new SyncStore<TModel>(config, this.context, this.database, this.retry, this.runScope, this.telemetry)
64
+ }
65
+
66
+ registerStores<TModel extends BaseModel>(stores: SyncStore<TModel>[]) {
67
+ return Object.fromEntries(stores.map(store => {
68
+ return [store.model.table, store]
69
+ })) as Record<string, SyncStore<TModel>>
70
+ }
71
+
72
+ storesForModels(models: ModelClass[]) {
73
+ return models.map(model => this.storesRegistry[model.table])
74
+ }
75
+
76
+ createStrategy<T extends SyncStrategy, U extends any[]>(
77
+ strategyClass: new (context: SyncContext, ...args: U) => T,
78
+ ...args: U
79
+ ): T {
80
+ return new strategyClass(this.context, ...args)
81
+ }
82
+
83
+ syncStoresWithStrategies(stores: SyncStore<any>[], strategies: SyncStrategy[]) {
84
+ this.strategyMap.push({ stores, strategies })
85
+ }
86
+
87
+ protectStores(
88
+ stores: SyncStore<any>[],
89
+ mechanisms: SyncConcurrencySafetyMechanism[]
90
+ ) {
91
+ const teardowns = mechanisms.map(mechanism => mechanism(this.context, stores))
92
+ this.safetyMap.push({ stores, mechanisms: teardowns })
93
+ }
94
+
95
+ setup() {
96
+ this.telemetry.debug('[SyncManager] Setting up')
97
+
98
+ this.context.start()
99
+ this.retry.start()
100
+
101
+ this.strategyMap.forEach(({ stores, strategies }) => {
102
+ strategies.forEach(strategy => {
103
+ stores.forEach(store => {
104
+ strategy.onTrigger(store, reason => {
105
+ store.requestSync(reason)
106
+ })
107
+ })
108
+ strategy.start()
109
+ })
110
+ })
111
+
112
+ const teardown = async () => {
113
+ this.telemetry.debug('[SyncManager] Tearing down')
114
+ this.runScope.abort()
115
+ this.strategyMap.forEach(({ strategies }) => strategies.forEach(strategy => strategy.stop()))
116
+ this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach(mechanism => mechanism()))
117
+ this.retry.stop()
118
+ this.context.stop()
119
+ await this.database.write(() => this.database.unsafeResetDatabase())
120
+ }
121
+ return teardown
122
+ }
123
+
124
+ getStore<TModel extends BaseModel>(model: ModelClass<TModel>) {
125
+ const store = this.storesRegistry[model.table]
126
+ if (!store) {
127
+ throw new SyncError(`Store not found`, { table: model.table })
128
+ }
129
+ return store as unknown as SyncStore<TModel>
130
+ }
131
+
132
+ getTelemetry() {
133
+ return this.telemetry
134
+ }
135
+
136
+ getContext() {
137
+ return this.context
138
+ }
139
+ }
@@ -0,0 +1,47 @@
1
+ import { Model, Collection, RawRecord } from '@nozbe/watermelondb'
2
+ import { EpochMs } from '..'
3
+
4
+ export default abstract class BaseModel<ExtraRaw extends object = {}> extends Model {
5
+ declare _raw: RawRecord & ExtraRaw & {
6
+ created_at: EpochMs
7
+ updated_at: EpochMs
8
+ }
9
+
10
+ get created_at() {
11
+ return this._getRaw('created_at') as EpochMs
12
+ }
13
+
14
+ get updated_at() {
15
+ return this._getRaw('updated_at') as EpochMs
16
+ }
17
+
18
+ static _prepareCreate(
19
+ collection: Collection<Model>,
20
+ recordBuilder: (record: Model) => void
21
+ ) {
22
+ return super._prepareCreate(collection, (record: Model) => {
23
+ const now = Date.now() as EpochMs
24
+ record._raw['created_at'] = now
25
+ record._raw['updated_at'] = now
26
+ recordBuilder(record)
27
+ })
28
+ }
29
+
30
+ prepareUpdate(recordBuilder: ((record: this) => void) | undefined) {
31
+ return super.prepareUpdate((record: this) => {
32
+ record._raw['updated_at'] = Date.now() as EpochMs
33
+ if (recordBuilder) recordBuilder(record)
34
+ })
35
+ }
36
+
37
+ prepareMarkAsDeleted() {
38
+ return super.prepareUpdate((record: this) => {
39
+ if (record._raw._status === 'deleted') {
40
+ return
41
+ }
42
+
43
+ record._raw['updated_at'] = Date.now() as EpochMs
44
+ record._raw._status = 'deleted'
45
+ })
46
+ }
47
+ }
@@ -0,0 +1,16 @@
1
+ import { SYNC_TABLES } from '../schema'
2
+ import BaseModel from './Base'
3
+
4
+ export default class ContentLike extends BaseModel<{
5
+ content_id: number
6
+ }> {
7
+ static table = SYNC_TABLES.CONTENT_LIKES
8
+
9
+ get content_id() {
10
+ return this._getRaw('content_id') as number
11
+ }
12
+
13
+ set content_id(value: number) {
14
+ this._setRaw('content_id', value)
15
+ }
16
+ }
@@ -0,0 +1,69 @@
1
+ import BaseModel from './Base'
2
+ import { SYNC_TABLES } from '../schema'
3
+
4
+ export enum COLLECTION_TYPE {
5
+ SKILL_PACK = 'skill-pack',
6
+ LEARNING_PATH = 'learning-path',
7
+ PLAYLIST = 'playlist',
8
+ }
9
+
10
+ export enum STATE {
11
+ STARTED = 'started',
12
+ COMPLETED = 'completed'
13
+ }
14
+
15
+ export default class ContentProgress extends BaseModel<{
16
+ content_id: number
17
+ collection_type: COLLECTION_TYPE | null
18
+ collection_id: number | null
19
+ state: STATE
20
+ progress_percent: number
21
+ resume_time_seconds: number
22
+ }> {
23
+ static table = SYNC_TABLES.CONTENT_PROGRESS
24
+
25
+ get content_id() {
26
+ return this._getRaw('content_id') as number
27
+ }
28
+ get content_brand() {
29
+ return this._getRaw('content_brand') as string
30
+ }
31
+ get state() {
32
+ return this._getRaw('state') as STATE
33
+ }
34
+ get progress_percent() {
35
+ return this._getRaw('progress_percent') as number
36
+ }
37
+ get collection_type() {
38
+ return (this._getRaw('collection_type') as COLLECTION_TYPE) || null
39
+ }
40
+ get collection_id() {
41
+ return (this._getRaw('collection_id') as number) || null
42
+ }
43
+ get resume_time_seconds() {
44
+ return this._getRaw('resume_time_seconds') as number
45
+ }
46
+
47
+ set content_id(value: number) {
48
+ this._setRaw('content_id', value)
49
+ }
50
+ set content_brand(value: string) {
51
+ this._setRaw('content_brand', value)
52
+ }
53
+ set state(value: STATE) {
54
+ this._setRaw('state', value)
55
+ }
56
+ set progress_percent(value: number) {
57
+ this._setRaw('progress_percent', value)
58
+ }
59
+ set collection_type(value: COLLECTION_TYPE | null) {
60
+ this._setRaw('collection_type', value)
61
+ }
62
+ set collection_id(value: number | null) {
63
+ this._setRaw('collection_id', value)
64
+ }
65
+ set resume_time_seconds(value: number) {
66
+ this._setRaw('resume_time_seconds', value)
67
+ }
68
+
69
+ }