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,310 @@
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
+ 'Content-Type': 'application/json',
121
+ 'X-Sync-Client-Id': session.getClientId(),
122
+ ...(session.getSessionId() ? {
123
+ 'X-Sync-Client-Session-Id': session.getSessionId()!
124
+ } : {})
125
+ }
126
+ })
127
+ }
128
+
129
+ export function handlePull(callback: (session: BaseSessionProvider) => Request) {
130
+ return async function(session: BaseSessionProvider, lastFetchToken: SyncToken | null, signal?: AbortSignal): Promise<SyncPullResponse> {
131
+ const generatedRequest = callback(session)
132
+ const url = serializePullUrlQuery(generatedRequest.url, lastFetchToken)
133
+ const request = new Request(url, {
134
+ credentials: 'include',
135
+ headers: generatedRequest.headers,
136
+ signal
137
+ });
138
+
139
+ let response: Response | null = null
140
+ try {
141
+ response = await performFetch(request)
142
+ } catch (e) {
143
+ return {
144
+ ok: false,
145
+ originalError: e
146
+ }
147
+ }
148
+
149
+ const json = await response.json() as RawPullResponse
150
+ const data = deserializePullResponse(json)
151
+
152
+ // if no max_updated_at, at least use the server's timestamp
153
+ // useful for recording that we have at least tried fetching even though resultset empty
154
+ const token = data.meta.max_updated_at || data.meta.timestamp
155
+ const previousToken = data.meta.since
156
+
157
+ const entries = data.entries
158
+
159
+ return {
160
+ ok: true,
161
+ entries,
162
+ token,
163
+ previousToken
164
+ }
165
+ }
166
+ }
167
+
168
+ export function handlePush(callback: (session: BaseSessionProvider) => Request) {
169
+ return async function(session: BaseSessionProvider, payload: PushPayload, signal?: AbortSignal): Promise<SyncPushResponse> {
170
+ const generatedRequest = callback(session)
171
+ const serverPayload = serializePushPayload(payload)
172
+ const request = new Request(generatedRequest, {
173
+ credentials: 'include',
174
+ body: JSON.stringify(serverPayload),
175
+ signal
176
+ })
177
+
178
+ let response: Response | null = null
179
+ try {
180
+ response = await performFetch(request)
181
+ } catch (e) {
182
+ return {
183
+ ok: false,
184
+ originalError: e
185
+ }
186
+ }
187
+
188
+ const json = await response.json() as RawPushResponse
189
+ const data = deserializePushResponse(json)
190
+
191
+ return {
192
+ ok: true,
193
+ results: data.results
194
+ }
195
+ }
196
+ }
197
+
198
+ async function performFetch(request: Request) {
199
+ const response = await fetch(request)
200
+ const isRetryable = (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
201
+
202
+ if (isRetryable) {
203
+ throw new Error(`Server returned ${response.status}`)
204
+ }
205
+
206
+ return response
207
+ }
208
+
209
+ function serializePullUrlQuery(url: string, fetchToken: SyncToken | null) {
210
+ const queryString = url.replace(/^[^?]*\??/, '');
211
+ const searchParams = new URLSearchParams(queryString);
212
+ if (fetchToken) {
213
+ searchParams.set('since', fetchToken.toString())
214
+ }
215
+ return url + '?' + searchParams.toString()
216
+ }
217
+
218
+ function deserializePullResponse(response: RawPullResponse) {
219
+ return {
220
+ ...response,
221
+ entries: response.entries.map(entry => {
222
+ return {
223
+ ...entry,
224
+ record: deserializeRecord(entry.record),
225
+ meta: {
226
+ ...entry.meta,
227
+ ids: deserializeIds(entry.meta.ids)
228
+ }
229
+ }
230
+ })
231
+ }
232
+ }
233
+
234
+ function serializePushPayload(payload: PushPayload): ServerPushPayload {
235
+ return {
236
+ ...payload,
237
+ entries: payload.entries.map(entry => {
238
+ return {
239
+ record: serializeRecord(entry.record),
240
+ meta: {
241
+ ...entry.meta,
242
+ ids: serializeIds(entry.meta.ids)
243
+ }
244
+ }
245
+ })
246
+ }
247
+ }
248
+
249
+ function deserializePushResponse(response: RawPushResponse) {
250
+ return {
251
+ results: response.results.map(result => {
252
+ if (result.type === 'success') {
253
+ const entry = result.entry
254
+
255
+ return {
256
+ ...result,
257
+ entry: {
258
+ ...entry,
259
+ record: deserializeRecord(entry.record),
260
+ meta: {
261
+ ...entry.meta,
262
+ ids: deserializeIds(entry.meta.ids)
263
+ }
264
+ }
265
+ }
266
+ } else {
267
+ return {
268
+ ...result,
269
+ ids: deserializeIds(result.ids)
270
+ }
271
+ }
272
+ })
273
+ }
274
+ }
275
+
276
+ function serializeRecord(record: SyncSyncable<BaseModel, 'id'> | null): SyncSyncable<BaseModel, 'client_record_id'> | null {
277
+ if (record) {
278
+ const { id, ...rest } = record
279
+ return {
280
+ ...rest,
281
+ client_record_id: id
282
+ }
283
+ }
284
+
285
+ return null
286
+ }
287
+
288
+ function serializeIds(ids: { id: RecordId }): { client_record_id: RecordId } {
289
+ return {
290
+ client_record_id: ids.id
291
+ }
292
+ }
293
+
294
+ function deserializeRecord(record: SyncSyncable<BaseModel, 'client_record_id'> | null): SyncSyncable<BaseModel, 'id'> | null {
295
+ if (record) {
296
+ const { client_record_id: id, ...rest } = record
297
+ return {
298
+ ...rest,
299
+ id
300
+ }
301
+ }
302
+
303
+ return null
304
+ }
305
+
306
+ function deserializeIds(ids: { client_record_id: RecordId }): { id: RecordId } {
307
+ return {
308
+ id: ids.client_record_id
309
+ }
310
+ }
@@ -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
+ }