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.
- package/CHANGELOG.md +31 -0
- package/docs/ContentOrganization.html +2 -2
- package/docs/Forums.html +2 -2
- package/docs/Gamification.html +2 -2
- package/docs/TestUser.html +2 -2
- package/docs/UserManagementSystem.html +2 -2
- package/docs/api_types.js.html +2 -2
- package/docs/config.js.html +2 -2
- package/docs/content-org_content-org.js.html +2 -2
- package/docs/content-org_guided-courses.ts.html +2 -2
- package/docs/content-org_learning-paths.ts.html +52 -40
- package/docs/content-org_playlists-types.js.html +2 -2
- package/docs/content-org_playlists.js.html +2 -2
- package/docs/content.js.html +2 -2
- package/docs/content_artist.ts.html +2 -2
- package/docs/content_genre.ts.html +2 -2
- package/docs/content_instructor.ts.html +2 -2
- package/docs/forums_categories.ts.html +2 -2
- package/docs/forums_forums.ts.html +2 -2
- package/docs/forums_posts.ts.html +2 -2
- package/docs/forums_threads.ts.html +2 -2
- package/docs/gamification_awards.ts.html +2 -2
- package/docs/gamification_gamification.js.html +2 -2
- package/docs/global.html +2 -2
- package/docs/index.html +2 -2
- package/docs/liveTesting.ts.html +2 -2
- package/docs/module-Accounts.html +2 -2
- package/docs/module-Artist.html +2 -2
- package/docs/module-Awards.html +2 -2
- package/docs/module-Config.html +2 -2
- package/docs/module-Content-Services-V2.html +2 -2
- package/docs/module-Forums.html +2 -2
- package/docs/module-Genre.html +2 -2
- package/docs/module-GuidedCourses.html +2 -2
- package/docs/module-Instructor.html +2 -2
- package/docs/module-Interests.html +2 -2
- package/docs/module-LearningPaths.html +269 -143
- package/docs/module-Onboarding.html +3 -3
- package/docs/module-Payments.html +2 -2
- package/docs/module-Permissions.html +2 -2
- package/docs/module-Playlists.html +2 -2
- package/docs/module-ProgressRow.html +2 -2
- package/docs/module-Railcontent-Services.html +34 -893
- package/docs/module-Sanity-Services.html +2 -2
- package/docs/module-Sessions.html +2 -2
- package/docs/module-UserActivity.html +70 -116
- package/docs/module-UserChat.html +2 -2
- package/docs/module-UserManagement.html +2 -2
- package/docs/module-UserMemberships.html +2 -2
- package/docs/module-UserNotifications.html +2 -2
- package/docs/module-UserProfile.html +2 -2
- package/docs/progress-row_method-card.js.html +3 -2
- package/docs/railcontent.js.html +14 -137
- package/docs/sanity.js.html +2 -2
- package/docs/userActivity.js.html +85 -150
- package/docs/user_account.ts.html +2 -2
- package/docs/user_chat.js.html +2 -2
- package/docs/user_interests.js.html +2 -2
- package/docs/user_management.js.html +2 -2
- package/docs/user_memberships.ts.html +2 -2
- package/docs/user_notifications.js.html +2 -2
- package/docs/user_onboarding.ts.html +10 -6
- package/docs/user_payments.ts.html +2 -2
- package/docs/user_permissions.js.html +2 -2
- package/docs/user_profile.js.html +2 -2
- package/docs/user_sessions.js.html +2 -2
- package/docs/user_types.js.html +2 -2
- package/docs/user_user-management-system.js.html +2 -2
- package/package.json +11 -3
- package/src/contentTypeConfig.js +6 -0
- package/src/index.d.ts +7 -31
- package/src/index.js +10 -34
- package/src/services/content-org/learning-paths.ts +31 -0
- package/src/services/contentAggregator.js +2 -2
- package/src/services/contentLikes.js +6 -39
- package/src/services/contentProgress.js +181 -479
- package/src/services/dataContext.js +0 -2
- package/src/services/progress-row/method-card.js +1 -0
- package/src/services/railcontent.js +12 -135
- package/src/services/sentry/.indexignore +0 -0
- package/src/services/sentry/index.ts +23 -0
- package/src/services/sync/.indexignore +0 -0
- package/src/services/sync/adapters/factory.ts +26 -0
- package/src/services/sync/adapters/lokijs.ts +1 -0
- package/src/services/sync/adapters/sqlite.ts +1 -0
- package/src/services/sync/concurrency-safety.ts +4 -0
- package/src/services/sync/context/index.ts +43 -0
- package/src/services/sync/context/providers/base.ts +4 -0
- package/src/services/sync/context/providers/connectivity.ts +14 -0
- package/src/services/sync/context/providers/durability.ts +5 -0
- package/src/services/sync/context/providers/index.ts +5 -0
- package/src/services/sync/context/providers/session.ts +8 -0
- package/src/services/sync/context/providers/tabs.ts +18 -0
- package/src/services/sync/context/providers/visibility.ts +14 -0
- package/src/services/sync/database/factory.ts +10 -0
- package/src/services/sync/errors/boundary.ts +45 -0
- package/src/services/sync/errors/index.ts +49 -0
- package/src/services/sync/fetch.ts +310 -0
- package/src/services/sync/index.ts +80 -0
- package/src/services/sync/manager.ts +139 -0
- package/src/services/sync/models/Base.ts +47 -0
- package/src/services/sync/models/ContentLike.ts +16 -0
- package/src/services/sync/models/ContentProgress.ts +69 -0
- package/src/services/sync/models/Practice.ts +72 -0
- package/src/services/sync/models/PracticeDayNote.ts +23 -0
- package/src/services/sync/models/index.ts +4 -0
- package/src/services/sync/repositories/base.ts +247 -0
- package/src/services/sync/repositories/content-likes.ts +26 -0
- package/src/services/sync/repositories/content-progress.ts +160 -0
- package/src/services/sync/repositories/index.ts +4 -0
- package/src/services/sync/repositories/practice-day-notes.ts +4 -0
- package/src/services/sync/repositories/practices.ts +52 -0
- package/src/services/sync/repository-proxy.ts +48 -0
- package/src/services/sync/resolver.ts +84 -0
- package/src/services/sync/retry.ts +88 -0
- package/src/services/sync/run-scope.ts +30 -0
- package/src/services/sync/schema/index.ts +66 -0
- package/src/services/sync/serializers/index.ts +2 -0
- package/src/services/sync/serializers/model.ts +32 -0
- package/src/services/sync/serializers/raw.ts +21 -0
- package/src/services/sync/store/index.ts +779 -0
- package/src/services/sync/store/push-coalescer.ts +57 -0
- package/src/services/sync/store-configs.ts +41 -0
- package/src/services/sync/strategies/base.ts +21 -0
- package/src/services/sync/strategies/index.ts +12 -0
- package/src/services/sync/strategies/initial.ts +11 -0
- package/src/services/sync/strategies/polling.ts +54 -0
- package/src/services/sync/telemetry/index.ts +140 -0
- package/src/services/sync/telemetry/sampling.ts +91 -0
- package/src/services/sync/utils/event-emitter.ts +24 -0
- package/src/services/sync/utils/index.ts +1 -0
- package/src/services/sync/utils/throttle.ts +93 -0
- package/src/services/sync/utils/timers.ts +9 -0
- package/src/services/userActivity.js +83 -148
- package/test/contentProgress.test.js +6 -39
- package/test/live/contentProgressLive.test.js +2 -31
- package/tools/generate-index.cjs +10 -4
- package/.claude/settings.local.json +0 -8
- 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
|
+
}
|