musora-content-services 2.122.1 → 2.122.4
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 +16 -0
- package/package.json +1 -1
- package/src/services/content-org/learning-paths.ts +64 -48
- package/src/services/contentProgress.js +7 -7
- package/src/services/sync/adapters/factory.ts +25 -10
- package/src/services/sync/adapters/lokijs.ts +14 -8
- package/src/services/sync/database/factory.ts +5 -4
- package/src/services/sync/fetch.ts +19 -11
- package/src/services/sync/index.ts +2 -0
- package/src/services/sync/manager.ts +114 -41
- package/src/services/sync/repositories/base.ts +2 -2
- package/src/services/sync/store/index.ts +54 -25
- package/src/services/sync/telemetry/index.ts +26 -9
- package/.claude/settings.local.json +0 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [2.122.4](https://github.com/railroadmedia/musora-content-services/compare/v2.122.3...v2.122.4) (2026-01-26)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* melon data user isolation ([#717](https://github.com/railroadmedia/musora-content-services/issues/717)) ([6893c3c](https://github.com/railroadmedia/musora-content-services/commit/6893c3c644e1eefcfeeb6439b460a46d853616d6))
|
|
11
|
+
|
|
12
|
+
### [2.122.3](https://github.com/railroadmedia/musora-content-services/compare/v2.122.2...v2.122.3) (2026-01-23)
|
|
13
|
+
|
|
14
|
+
### [2.122.2](https://github.com/railroadmedia/musora-content-services/compare/v2.122.1...v2.122.2) (2026-01-23)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* broken method index ([#735](https://github.com/railroadmedia/musora-content-services/issues/735)) ([126f985](https://github.com/railroadmedia/musora-content-services/commit/126f985866f60bcca1d1ddb2dd97ba8847ff4219))
|
|
20
|
+
|
|
5
21
|
### [2.122.1](https://github.com/railroadmedia/musora-content-services/compare/v2.122.0...v2.122.1) (2026-01-23)
|
|
6
22
|
|
|
7
23
|
|
package/package.json
CHANGED
|
@@ -56,17 +56,20 @@ interface CollectionObject {
|
|
|
56
56
|
* @param userDate - local datetime. must have date and time - format 2025-10-31T13:45:00
|
|
57
57
|
* @param forceRefresh - force cache refresh
|
|
58
58
|
*/
|
|
59
|
-
export async function getDailySession(
|
|
59
|
+
export async function getDailySession(
|
|
60
|
+
brand: string,
|
|
61
|
+
userDate: Date,
|
|
62
|
+
forceRefresh: boolean = false
|
|
63
|
+
) {
|
|
60
64
|
const dateWithTimezone = formatLocalDateTime(userDate)
|
|
61
65
|
const url: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
|
|
62
66
|
try {
|
|
63
|
-
const response = await dataPromiseGET(url, forceRefresh) as DailySessionResponse
|
|
67
|
+
const response = (await dataPromiseGET(url, forceRefresh)) as DailySessionResponse
|
|
64
68
|
if (!response) {
|
|
65
69
|
return await updateDailySession(brand, userDate, false)
|
|
66
70
|
}
|
|
67
71
|
dailySessionPromise = null // reset promise after successful fetch
|
|
68
72
|
return response as DailySessionResponse
|
|
69
|
-
|
|
70
73
|
} catch (error: any) {
|
|
71
74
|
if (error.status === 204) {
|
|
72
75
|
return await updateDailySession(brand, userDate, false)
|
|
@@ -88,16 +91,23 @@ export async function updateDailySession(
|
|
|
88
91
|
) {
|
|
89
92
|
const dateWithTimezone = formatLocalDateTime(userDate)
|
|
90
93
|
const url: string = `${LEARNING_PATHS_PATH}/daily-session/create`
|
|
91
|
-
const body = {
|
|
94
|
+
const body = {
|
|
95
|
+
brand: brand,
|
|
96
|
+
userDate: dateWithTimezone,
|
|
97
|
+
keepFirstLearningPath: keepFirstLearningPath,
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const response = (await POST(url, body)) as DailySessionResponse
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
if (response) {
|
|
103
|
+
const urlGet: string = `${LEARNING_PATHS_PATH}/daily-session/get?brand=${brand}&userDate=${encodeURIComponent(dateWithTimezone)}`
|
|
104
|
+
dataPromiseGET(urlGet, true) // refresh cache
|
|
105
|
+
}
|
|
94
106
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
return response
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
return null
|
|
98
110
|
}
|
|
99
|
-
|
|
100
|
-
return response
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
function formatLocalDateTime(date: Date): string {
|
|
@@ -112,7 +122,7 @@ function formatLocalDateTime(date: Date): string {
|
|
|
112
122
|
export async function getActivePath(brand: string, forceRefresh: boolean = false) {
|
|
113
123
|
const url: string = `${LEARNING_PATHS_PATH}/active-path/get?brand=${brand}`
|
|
114
124
|
|
|
115
|
-
const response = await dataPromiseGET(url, forceRefresh) as ActiveLearningPathResponse
|
|
125
|
+
const response = (await dataPromiseGET(url, forceRefresh)) as ActiveLearningPathResponse
|
|
116
126
|
activePathPromise = null // reset promise after successful fetch
|
|
117
127
|
|
|
118
128
|
return response
|
|
@@ -127,7 +137,7 @@ export async function startLearningPath(brand: string, learningPathId: number) {
|
|
|
127
137
|
const url: string = `${LEARNING_PATHS_PATH}/active-path/set`
|
|
128
138
|
const body = { brand: brand, learning_path_id: learningPathId }
|
|
129
139
|
|
|
130
|
-
const response = await POST(url, body) as ActiveLearningPathResponse
|
|
140
|
+
const response = (await POST(url, body)) as ActiveLearningPathResponse
|
|
131
141
|
|
|
132
142
|
// manual BE call to avoid recursive POST<->GET calls
|
|
133
143
|
if (response) {
|
|
@@ -138,16 +148,22 @@ export async function startLearningPath(brand: string, learningPathId: number) {
|
|
|
138
148
|
return response
|
|
139
149
|
}
|
|
140
150
|
|
|
141
|
-
async function dataPromiseGET(
|
|
151
|
+
async function dataPromiseGET(
|
|
152
|
+
url: string,
|
|
153
|
+
forceRefresh: boolean
|
|
154
|
+
): Promise<DailySessionResponse | ActiveLearningPathResponse> {
|
|
142
155
|
if (url.includes('daily-session')) {
|
|
143
156
|
if (!dailySessionPromise || forceRefresh) {
|
|
144
|
-
dailySessionPromise = GET(url, {
|
|
157
|
+
dailySessionPromise = GET(url, {
|
|
158
|
+
cache: forceRefresh ? 'reload' : 'default',
|
|
159
|
+
}) as Promise<DailySessionResponse>
|
|
145
160
|
}
|
|
146
161
|
return dailySessionPromise
|
|
147
|
-
|
|
148
162
|
} else if (url.includes('active-path')) {
|
|
149
163
|
if (!activePathPromise || forceRefresh) {
|
|
150
|
-
activePathPromise = GET(url, {
|
|
164
|
+
activePathPromise = GET(url, {
|
|
165
|
+
cache: forceRefresh ? 'reload' : 'default',
|
|
166
|
+
}) as Promise<ActiveLearningPathResponse>
|
|
151
167
|
}
|
|
152
168
|
return activePathPromise
|
|
153
169
|
}
|
|
@@ -183,14 +199,10 @@ export async function getEnrichedLearningPath(learningPathId) {
|
|
|
183
199
|
}
|
|
184
200
|
)) as any
|
|
185
201
|
// add awards to LP parents only
|
|
186
|
-
response = await addContextToLearningPaths(() => response, {addAwards:true})
|
|
202
|
+
response = await addContextToLearningPaths(() => response, { addAwards: true })
|
|
187
203
|
if (!response) return response
|
|
188
204
|
|
|
189
|
-
response.children = mapContentToParent(
|
|
190
|
-
response.children,
|
|
191
|
-
LEARNING_PATH_LESSON,
|
|
192
|
-
learningPathId
|
|
193
|
-
)
|
|
205
|
+
response.children = mapContentToParent(response.children, LEARNING_PATH_LESSON, learningPathId)
|
|
194
206
|
return response
|
|
195
207
|
}
|
|
196
208
|
|
|
@@ -216,7 +228,7 @@ export async function getEnrichedLearningPaths(learningPathIds: number[]) {
|
|
|
216
228
|
}
|
|
217
229
|
)) as any
|
|
218
230
|
// add awards to LP parents only
|
|
219
|
-
response = await addContextToLearningPaths(() => response, {addAwards:true})
|
|
231
|
+
response = await addContextToLearningPaths(() => response, { addAwards: true })
|
|
220
232
|
|
|
221
233
|
if (!response) return response
|
|
222
234
|
|
|
@@ -300,9 +312,9 @@ export async function fetchLearningPathLessons(
|
|
|
300
312
|
userDate: Date
|
|
301
313
|
) {
|
|
302
314
|
const learningPath = await getEnrichedLearningPath(learningPathId)
|
|
303
|
-
let dailySession = await getDailySession(brand, userDate) as DailySessionResponse // what if the call just fails, and a DS does exist?
|
|
315
|
+
let dailySession = (await getDailySession(brand, userDate)) as DailySessionResponse // what if the call just fails, and a DS does exist?
|
|
304
316
|
if (!dailySession) {
|
|
305
|
-
dailySession = await updateDailySession(brand, userDate, false) as DailySessionResponse
|
|
317
|
+
dailySession = (await updateDailySession(brand, userDate, false)) as DailySessionResponse
|
|
306
318
|
}
|
|
307
319
|
|
|
308
320
|
const isActiveLearningPath = (dailySession?.active_learning_path_id || 0) == learningPathId
|
|
@@ -320,8 +332,6 @@ export async function fetchLearningPathLessons(
|
|
|
320
332
|
let previousContentIds = []
|
|
321
333
|
let previousLearningPathId = null
|
|
322
334
|
|
|
323
|
-
|
|
324
|
-
|
|
325
335
|
for (const session of dailySession.daily_session) {
|
|
326
336
|
if (session.learning_path_id === learningPathId) {
|
|
327
337
|
todayContentIds = session.content_ids || []
|
|
@@ -361,13 +371,13 @@ export async function fetchLearningPathLessons(
|
|
|
361
371
|
)
|
|
362
372
|
}
|
|
363
373
|
if (nextContentIds.length !== 0) {
|
|
364
|
-
nextLPDailies = await getLearningPathLessonsByIds(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
374
|
+
nextLPDailies = await getLearningPathLessonsByIds(nextContentIds, nextLearningPathId).then(
|
|
375
|
+
(lessons) =>
|
|
376
|
+
lessons.map((lesson) => ({
|
|
377
|
+
...lesson,
|
|
378
|
+
in_next_learning_path: learningPath.progressStatus === STATE.COMPLETED,
|
|
379
|
+
}))
|
|
380
|
+
)
|
|
371
381
|
}
|
|
372
382
|
|
|
373
383
|
return {
|
|
@@ -421,22 +431,28 @@ export async function completeMethodIntroVideo(
|
|
|
421
431
|
const methodStructure = await fetchMethodV2Structure(brand)
|
|
422
432
|
|
|
423
433
|
const firstLearningPathId = methodStructure.learning_paths[0].id
|
|
424
|
-
response.active_path_response = await methodIntroVideoCompleteActions(
|
|
434
|
+
response.active_path_response = await methodIntroVideoCompleteActions(
|
|
435
|
+
brand,
|
|
436
|
+
firstLearningPathId,
|
|
437
|
+
new Date()
|
|
438
|
+
)
|
|
425
439
|
|
|
426
440
|
response.intro_video_response = await completeIfNotCompleted(introVideoId)
|
|
427
441
|
|
|
428
442
|
return response
|
|
429
443
|
}
|
|
430
444
|
|
|
431
|
-
async function methodIntroVideoCompleteActions(
|
|
445
|
+
async function methodIntroVideoCompleteActions(
|
|
446
|
+
brand: string,
|
|
447
|
+
learningPathId: number,
|
|
448
|
+
userDate: Date
|
|
449
|
+
) {
|
|
432
450
|
const stringDate = userDate.toISOString().split('T')[0]
|
|
433
451
|
const url: string = `${LEARNING_PATHS_PATH}/method-intro-video-complete-actions`
|
|
434
452
|
const body = { brand: brand, learningPathId: learningPathId, userDate: stringDate }
|
|
435
453
|
return (await POST(url, body)) as DailySessionResponse
|
|
436
454
|
}
|
|
437
455
|
|
|
438
|
-
|
|
439
|
-
|
|
440
456
|
interface completeLearningPathIntroVideo {
|
|
441
457
|
intro_video_response: SyncWriteDTO<ContentProgress, any> | null
|
|
442
458
|
learning_path_reset_response: SyncWriteDTO<ContentProgress, any> | null
|
|
@@ -465,19 +481,13 @@ export async function completeLearningPathIntroVideo(
|
|
|
465
481
|
const collection: CollectionObject = { id: learningPathId, type: COLLECTION_TYPE.LEARNING_PATH }
|
|
466
482
|
|
|
467
483
|
if (!lessonsToImport) {
|
|
468
|
-
|
|
469
484
|
response.learning_path_reset_response = await resetIfPossible(learningPathId, collection)
|
|
470
485
|
} else {
|
|
471
|
-
|
|
472
486
|
response.lesson_import_response = await contentStatusCompletedMany(lessonsToImport, collection)
|
|
473
487
|
const activePath = await getActivePath(brand)
|
|
474
488
|
|
|
475
489
|
if (activePath.active_learning_path_id === learningPathId) {
|
|
476
|
-
response.update_dailies_response = await updateDailySession(
|
|
477
|
-
brand,
|
|
478
|
-
new Date(),
|
|
479
|
-
true
|
|
480
|
-
)
|
|
490
|
+
response.update_dailies_response = await updateDailySession(brand, new Date(), true)
|
|
481
491
|
}
|
|
482
492
|
}
|
|
483
493
|
|
|
@@ -494,13 +504,19 @@ async function completeIfNotCompleted(
|
|
|
494
504
|
return introVideoStatus !== 'completed' ? await contentStatusCompleted(contentId) : null
|
|
495
505
|
}
|
|
496
506
|
|
|
497
|
-
async function resetIfPossible(
|
|
507
|
+
async function resetIfPossible(
|
|
508
|
+
contentId: number,
|
|
509
|
+
collection: CollectionParameter = null
|
|
510
|
+
): Promise<SyncWriteDTO<ContentProgress, any> | null> {
|
|
498
511
|
const status = await getProgressState(contentId, collection)
|
|
499
512
|
|
|
500
513
|
return status !== '' ? await contentStatusReset(contentId, collection) : null
|
|
501
514
|
}
|
|
502
515
|
|
|
503
|
-
export async function onContentCompletedLearningPathActions(
|
|
516
|
+
export async function onContentCompletedLearningPathActions(
|
|
517
|
+
contentId: number,
|
|
518
|
+
collection: CollectionObject | null
|
|
519
|
+
) {
|
|
504
520
|
if (collection?.type !== COLLECTION_TYPE.LEARNING_PATH) return
|
|
505
521
|
if (contentId !== collection?.id) return
|
|
506
522
|
|
|
@@ -525,5 +541,5 @@ export async function onContentCompletedLearningPathActions(contentId: number, c
|
|
|
525
541
|
await startLearningPath(brand, nextLearningPath.id)
|
|
526
542
|
const nextLearningPathData = await getEnrichedLearningPath(nextLearningPath.id)
|
|
527
543
|
|
|
528
|
-
await contentStatusReset(nextLearningPathData.intro_video.id, {skipPush: true})
|
|
544
|
+
await contentStatusReset(nextLearningPathData.intro_video.id, { skipPush: true })
|
|
529
545
|
}
|
|
@@ -444,8 +444,8 @@ export async function flushWatchSession(sessionToFlush = null, shouldClearInterv
|
|
|
444
444
|
sessionToFlush.pushInterval = null
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
db.contentProgress.requestPushUnsynced()
|
|
448
|
-
db.practices.requestPushUnsynced()
|
|
447
|
+
db.contentProgress.requestPushUnsynced('flush-watch-session')
|
|
448
|
+
db.practices.requestPushUnsynced('flush-watch-session')
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
async function trackPractice(contentId, secondsPlayed, practiceSession, details = {}) {
|
|
@@ -515,7 +515,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
515
515
|
|
|
516
516
|
// skip bubbling if progress hasnt changed
|
|
517
517
|
if (progress === currentProgress) {
|
|
518
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
518
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
519
519
|
return
|
|
520
520
|
}
|
|
521
521
|
|
|
@@ -549,7 +549,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
552
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
553
553
|
|
|
554
554
|
return response
|
|
555
555
|
}
|
|
@@ -582,7 +582,7 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
|
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
585
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status')
|
|
586
586
|
|
|
587
587
|
return response
|
|
588
588
|
}
|
|
@@ -627,7 +627,7 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
|
|
|
627
627
|
}
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
630
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('set-started-or-completed-status-many')
|
|
631
631
|
|
|
632
632
|
return response
|
|
633
633
|
}
|
|
@@ -665,7 +665,7 @@ async function resetStatus(contentId, collection = null, {skipPush = false} = {}
|
|
|
665
665
|
await duplicateLearningPathProgressToExternalContents(progresses, collection, hierarchy, {skipPush: true})
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
if (!skipPush) db.contentProgress.requestPushUnsynced()
|
|
668
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('reset-status')
|
|
669
669
|
|
|
670
670
|
return response
|
|
671
671
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import schema from '../schema'
|
|
2
|
+
import type { SyncUserScope } from '../index'
|
|
3
|
+
import { SyncError } from '../errors'
|
|
2
4
|
|
|
3
5
|
import type SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'
|
|
4
6
|
import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
5
7
|
|
|
6
|
-
import { SyncTelemetry } from '../telemetry'
|
|
7
|
-
|
|
8
8
|
export type DatabaseAdapter = SQLiteAdapter | LokiJSAdapter
|
|
9
9
|
|
|
10
10
|
type SQLiteAdapterOptions = ConstructorParameters<typeof SQLiteAdapter>[0]
|
|
@@ -14,13 +14,28 @@ type DatabaseAdapterOptions = SQLiteAdapterOptions & LokiJSAdapterOptions
|
|
|
14
14
|
|
|
15
15
|
export default function syncAdapterFactory<T extends DatabaseAdapter>(
|
|
16
16
|
AdapterClass: new (options: DatabaseAdapterOptions) => T,
|
|
17
|
-
_namespace: string,
|
|
18
17
|
opts: Omit<DatabaseAdapterOptions, 'schema' | 'migrations'>
|
|
19
|
-
): () => T {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
): (userScope: SyncUserScope) => T {
|
|
19
|
+
// IMPORTANT: we rely on namespaced databases to prevent data clobbering
|
|
20
|
+
// when localStorage.userId somehow changes outside of an explicit, app-managed logout
|
|
21
|
+
// the system always checks on writes, pushes, and pulls that the localstorage.userId matches the userScope.initialId
|
|
22
|
+
// but without namespaced dbs, that would not be sufficient to prevent a database with data for user A
|
|
23
|
+
// from later masquerading as a database for user B
|
|
24
|
+
|
|
25
|
+
// This also allows us to keep the entire setup flow synchronous and lazy
|
|
26
|
+
// i.e., no checks necessary on database initialization comparing the previous/stored userId to the new/intended/current userId
|
|
27
|
+
// (and a panicked resetting if those checks fail)
|
|
28
|
+
|
|
29
|
+
return (userScope: SyncUserScope) => {
|
|
30
|
+
if (!userScope.initialId) {
|
|
31
|
+
throw new SyncError('User ID is required to construct database adapter')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return new AdapterClass({
|
|
35
|
+
...opts,
|
|
36
|
+
dbName: `sync:${userScope.initialId}`,
|
|
37
|
+
schema,
|
|
38
|
+
migrations: undefined
|
|
39
|
+
})
|
|
40
|
+
}
|
|
26
41
|
}
|
|
@@ -3,7 +3,7 @@ import { SyncTelemetry } from '../telemetry'
|
|
|
3
3
|
import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
4
4
|
export { LokiJSAdapter as default }
|
|
5
5
|
|
|
6
|
-
import { deleteDatabase } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
|
|
6
|
+
import { deleteDatabase, lokiFatalError } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Mute impending driver errors that are expected after sync adapter failure
|
|
@@ -75,6 +75,12 @@ export function simulateIndexedDBQuotaExceeded() {
|
|
|
75
75
|
})
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
export function abortWritesToDatabase(adapter: LokiJSAdapter) {
|
|
79
|
+
// acts as handy helper to disable loki's save methods entirely
|
|
80
|
+
lokiFatalError(adapter._driver.loki)
|
|
81
|
+
return Promise.resolve()
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
/**
|
|
79
85
|
* Completely destroy database, as opposed to watermelon's reset
|
|
80
86
|
* (which merely clears all records but re-initializes the database schema)
|
|
@@ -84,18 +90,18 @@ export function simulateIndexedDBQuotaExceeded() {
|
|
|
84
90
|
export function destroyDatabase(dbName: string, adapter: LokiJSAdapter): Promise<void> {
|
|
85
91
|
return new Promise(async (resolve, reject) => {
|
|
86
92
|
if (adapter._driver) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
try {
|
|
94
|
+
// good manners to clear the cache, even though this adapter will likely be discarded
|
|
95
|
+
adapter._clearCachedRecords();
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
SyncTelemetry.getInstance()?.capture(err)
|
|
98
|
+
}
|
|
93
99
|
|
|
94
100
|
try {
|
|
95
101
|
await deleteDatabase(adapter._driver.loki)
|
|
96
102
|
return resolve();
|
|
97
103
|
} catch (err: unknown) {
|
|
98
|
-
SyncTelemetry.getInstance()?.capture(err
|
|
104
|
+
SyncTelemetry.getInstance()?.capture(err)
|
|
99
105
|
return reject(err);
|
|
100
106
|
}
|
|
101
107
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { DatabaseAdapter } from '../adapters/factory'
|
|
2
|
-
import { Database
|
|
2
|
+
import { Database } from '@nozbe/watermelondb'
|
|
3
3
|
import * as modelClasses from '../models'
|
|
4
|
+
import type { SyncUserScope } from '../index'
|
|
4
5
|
|
|
5
|
-
export default function syncDatabaseFactory(adapter: () => DatabaseAdapter, { onInitError }: { onInitError?: (error: Error) => void } = {}) {
|
|
6
|
-
return () => {
|
|
6
|
+
export default function syncDatabaseFactory(adapter: (userScope: SyncUserScope) => DatabaseAdapter, { onInitError }: { onInitError?: (error: Error) => void } = {}) {
|
|
7
|
+
return (userScope: SyncUserScope) => {
|
|
7
8
|
try {
|
|
8
9
|
return new Database({
|
|
9
|
-
adapter: adapter(),
|
|
10
|
+
adapter: adapter(userScope),
|
|
10
11
|
modelClasses: Object.values(modelClasses)
|
|
11
12
|
})
|
|
12
13
|
} catch (error) {
|
|
@@ -69,7 +69,8 @@ type SyncPullSuccessResponse = SyncResponseBase & {
|
|
|
69
69
|
ok: true
|
|
70
70
|
entries: SyncEntry[]
|
|
71
71
|
token: SyncToken
|
|
72
|
-
previousToken: SyncToken | null
|
|
72
|
+
previousToken: SyncToken | null,
|
|
73
|
+
intendedUserId: number
|
|
73
74
|
}
|
|
74
75
|
export type SyncPullFetchFailureResponse = SyncResponseBase & {
|
|
75
76
|
ok: false,
|
|
@@ -117,8 +118,8 @@ interface ServerPushPayload {
|
|
|
117
118
|
}[]
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (session: BaseSessionProvider) => Request {
|
|
121
|
-
return (session) => new Request(globalConfig.baseUrl + input, {
|
|
121
|
+
export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (userId: number, session: BaseSessionProvider) => Request {
|
|
122
|
+
return (userId, session) => new Request(globalConfig.baseUrl + input, {
|
|
122
123
|
...init,
|
|
123
124
|
headers: {
|
|
124
125
|
...init?.headers,
|
|
@@ -129,14 +130,15 @@ export function makeFetchRequest(input: RequestInfo, init?: RequestInit): (sessi
|
|
|
129
130
|
'X-Sync-Client-Id': session.getClientId(),
|
|
130
131
|
...(session.getSessionId() ? {
|
|
131
132
|
'X-Sync-Client-Session-Id': session.getSessionId()!
|
|
132
|
-
} : {})
|
|
133
|
+
} : {}),
|
|
134
|
+
'X-Sync-Intended-User-Id': userId.toString()
|
|
133
135
|
}
|
|
134
136
|
})
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
export function handlePull(callback: (session: BaseSessionProvider) => Request) {
|
|
138
|
-
return async function(session: BaseSessionProvider, lastFetchToken: SyncToken | null, signal?: AbortSignal): Promise<SyncPullResponse> {
|
|
139
|
-
const generatedRequest = callback(session)
|
|
139
|
+
export function handlePull(callback: (userId: number, session: BaseSessionProvider) => Request) {
|
|
140
|
+
return async function(userId: number, session: BaseSessionProvider, lastFetchToken: SyncToken | null, signal?: AbortSignal): Promise<SyncPullResponse> {
|
|
141
|
+
const generatedRequest = callback(userId, session)
|
|
140
142
|
const url = serializePullUrlQuery(generatedRequest.url, lastFetchToken)
|
|
141
143
|
const request = new Request(url, {
|
|
142
144
|
credentials: 'include',
|
|
@@ -163,6 +165,11 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
if (!response.headers.get('X-Sync-Intended-User-Id')) {
|
|
169
|
+
throw new Error('Missing X-Sync-Intended-User-Id header')
|
|
170
|
+
}
|
|
171
|
+
const intendedUserId = +response.headers.get('X-Sync-Intended-User-Id')!
|
|
172
|
+
|
|
166
173
|
const json = await response.json() as RawPullResponse
|
|
167
174
|
const data = deserializePullResponse(json)
|
|
168
175
|
|
|
@@ -177,14 +184,15 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
|
|
|
177
184
|
ok: true,
|
|
178
185
|
entries,
|
|
179
186
|
token,
|
|
180
|
-
previousToken
|
|
187
|
+
previousToken,
|
|
188
|
+
intendedUserId
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
export function handlePush(callback: (session: BaseSessionProvider) => Request) {
|
|
186
|
-
return async function(session: BaseSessionProvider, payload: PushPayload, signal?: AbortSignal): Promise<SyncPushResponse> {
|
|
187
|
-
const generatedRequest = callback(session)
|
|
193
|
+
export function handlePush(callback: (userId: number, session: BaseSessionProvider) => Request) {
|
|
194
|
+
return async function(userId: number, session: BaseSessionProvider, payload: PushPayload, signal?: AbortSignal): Promise<SyncPushResponse> {
|
|
195
|
+
const generatedRequest = callback(userId, session)
|
|
188
196
|
const serverPayload = serializePushPayload(payload)
|
|
189
197
|
const request = new Request(generatedRequest, {
|
|
190
198
|
credentials: 'include',
|
|
@@ -4,6 +4,8 @@ import { Q, RecordId } from "@nozbe/watermelondb"
|
|
|
4
4
|
import { type ModelSerialized, type RawSerialized } from "./serializers"
|
|
5
5
|
import BaseModel from "./models/Base"
|
|
6
6
|
|
|
7
|
+
export type SyncUserScope = { initialId: number, getCurrentId: () => number }
|
|
8
|
+
|
|
7
9
|
export { default as db } from './repository-proxy'
|
|
8
10
|
export { Q }
|
|
9
11
|
|
|
@@ -5,7 +5,7 @@ import SyncRunScope from './run-scope'
|
|
|
5
5
|
import { SyncStrategy } from './strategies'
|
|
6
6
|
import { default as SyncStore, SyncStoreConfig } from './store'
|
|
7
7
|
|
|
8
|
-
import { ModelClass } from './index'
|
|
8
|
+
import { ModelClass, SyncUserScope } from './index'
|
|
9
9
|
import SyncRetry from './retry'
|
|
10
10
|
import SyncContext from './context'
|
|
11
11
|
import { SyncError } from './errors'
|
|
@@ -14,6 +14,8 @@ import { SyncTelemetry } from './telemetry/index'
|
|
|
14
14
|
import createStoreConfigs from './store-configs'
|
|
15
15
|
import { contentProgressObserver } from '../awards/internal/content-progress-observer'
|
|
16
16
|
|
|
17
|
+
export type SyncTeardownMode = 'reset' | 'destroyOrReset' | 'abortWrites'
|
|
18
|
+
|
|
17
19
|
export default class SyncManager {
|
|
18
20
|
private static counter = 0
|
|
19
21
|
private static instance: SyncManager | null = null
|
|
@@ -22,13 +24,13 @@ export default class SyncManager {
|
|
|
22
24
|
if (SyncManager.instance) {
|
|
23
25
|
throw new SyncError('SyncManager already initialized')
|
|
24
26
|
}
|
|
25
|
-
|
|
26
27
|
SyncManager.instance = instance
|
|
28
|
+
|
|
27
29
|
const teardown = instance.setup()
|
|
28
30
|
|
|
29
|
-
return (
|
|
31
|
+
return async (mode: SyncTeardownMode = 'reset') => {
|
|
30
32
|
SyncManager.instance = null
|
|
31
|
-
return teardown(
|
|
33
|
+
return teardown(mode).catch((error) => {
|
|
32
34
|
SyncManager.instance = instance // restore instance on teardown failure
|
|
33
35
|
throw error
|
|
34
36
|
})
|
|
@@ -49,6 +51,8 @@ export default class SyncManager {
|
|
|
49
51
|
private id: string
|
|
50
52
|
public telemetry: SyncTelemetry
|
|
51
53
|
private context: SyncContext
|
|
54
|
+
private userScope: SyncUserScope
|
|
55
|
+
|
|
52
56
|
private storeConfigsRegistry: Record<string, SyncStoreConfig<any>>
|
|
53
57
|
private storesRegistry: Record<string, SyncStore<any>>
|
|
54
58
|
private runScope: SyncRunScope
|
|
@@ -56,17 +60,30 @@ export default class SyncManager {
|
|
|
56
60
|
private strategyMap: { models: ModelClass[]; strategies: SyncStrategy[] }[]
|
|
57
61
|
private effectMap: { models: ModelClass[]; effects: SyncEffect[] }[]
|
|
58
62
|
|
|
59
|
-
private initDatabase: () => Database
|
|
63
|
+
private initDatabase: (userScope: SyncUserScope) => Database
|
|
60
64
|
private destroyDatabase?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
private abortWritesToDatabase?: (adapter: DatabaseAdapter) => Promise<void>
|
|
66
|
+
|
|
67
|
+
private teardownPromise: Promise<void> | null = null
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
userScope: SyncUserScope,
|
|
71
|
+
context: SyncContext,
|
|
72
|
+
initDatabase: (userScope: SyncUserScope) => Database,
|
|
73
|
+
teardownDatabase: {
|
|
74
|
+
destroy?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>
|
|
75
|
+
abort?: (adapter: DatabaseAdapter) => Promise<void>
|
|
76
|
+
} = {}
|
|
77
|
+
) {
|
|
63
78
|
this.id = (SyncManager.counter++).toString()
|
|
64
79
|
|
|
65
80
|
this.telemetry = SyncTelemetry.getInstance()!
|
|
66
81
|
this.context = context
|
|
82
|
+
this.userScope = userScope
|
|
67
83
|
|
|
68
84
|
this.initDatabase = initDatabase
|
|
69
|
-
this.destroyDatabase =
|
|
85
|
+
this.destroyDatabase = teardownDatabase.destroy
|
|
86
|
+
this.abortWritesToDatabase = teardownDatabase.abort
|
|
70
87
|
|
|
71
88
|
this.storeConfigsRegistry = this.registerStoreConfigs(createStoreConfigs())
|
|
72
89
|
this.storesRegistry = {}
|
|
@@ -88,6 +105,7 @@ export default class SyncManager {
|
|
|
88
105
|
createStore(config: SyncStoreConfig, database: Database) {
|
|
89
106
|
return new SyncStore(
|
|
90
107
|
config,
|
|
108
|
+
this.userScope,
|
|
91
109
|
this.context,
|
|
92
110
|
database,
|
|
93
111
|
this.retry,
|
|
@@ -124,7 +142,10 @@ export default class SyncManager {
|
|
|
124
142
|
|
|
125
143
|
// can fail synchronously immediately (e.g., schema/migration validation errors)
|
|
126
144
|
// or asynchronously (e.g., indexedDB errors synchronously OR asynchronously (!))
|
|
127
|
-
const database = this.telemetry.trace(
|
|
145
|
+
const database = this.telemetry.trace(
|
|
146
|
+
{ name: 'db:init', attributes: { ...this.context.session.toJSON() } },
|
|
147
|
+
() => this.initDatabase(this.userScope)
|
|
148
|
+
)
|
|
128
149
|
|
|
129
150
|
Object.entries(this.storeConfigsRegistry).forEach(([table, storeConfig]) => {
|
|
130
151
|
this.storesRegistry[table] = this.createStore(storeConfig, database)
|
|
@@ -146,49 +167,101 @@ export default class SyncManager {
|
|
|
146
167
|
})
|
|
147
168
|
|
|
148
169
|
const effectTeardowns = this.effectMap.flatMap(({ models, effects }) => {
|
|
149
|
-
return effects.map((effect) =>
|
|
150
|
-
|
|
170
|
+
return effects.map((effect) =>
|
|
171
|
+
effect(
|
|
172
|
+
this.context,
|
|
173
|
+
models.map((model) => this.storesRegistry[model.table])
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
})
|
|
151
177
|
|
|
152
178
|
contentProgressObserver.start(database).catch((error) => {
|
|
153
179
|
this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
|
|
154
180
|
})
|
|
155
181
|
|
|
156
|
-
const teardown = async (
|
|
157
|
-
this.
|
|
182
|
+
const teardown = async (mode: SyncTeardownMode = 'reset') => {
|
|
183
|
+
if (this.teardownPromise) {
|
|
184
|
+
this.telemetry.debug(
|
|
185
|
+
'[SyncManager] Teardown already in progress, returning existing promise'
|
|
186
|
+
)
|
|
187
|
+
return this.teardownPromise
|
|
188
|
+
}
|
|
158
189
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
this.teardownPromise = (async () => {
|
|
191
|
+
this.telemetry.debug('[SyncManager] Tearing down')
|
|
192
|
+
|
|
193
|
+
const clear = (mode: SyncTeardownMode) => {
|
|
194
|
+
if (mode === 'abortWrites') {
|
|
195
|
+
if (!this.abortWritesToDatabase) {
|
|
196
|
+
throw new SyncError('Cannot abort writes to database - implementation not provided')
|
|
197
|
+
}
|
|
198
|
+
if (!database.adapter.underlyingAdapter) {
|
|
199
|
+
throw new SyncError('Cannot abort writes to database - adapter not available')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return this.abortWritesToDatabase(database.adapter.underlyingAdapter)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (mode === 'destroyOrReset') {
|
|
206
|
+
try {
|
|
207
|
+
if (!this.destroyDatabase) {
|
|
208
|
+
throw new SyncError(
|
|
209
|
+
'Cannot destroy or reset database - destroy implementation not provided'
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
if (!database.adapter.underlyingAdapter) {
|
|
213
|
+
throw new SyncError('Cannot destroy database - adapter not available')
|
|
214
|
+
}
|
|
215
|
+
if (!database.adapter.dbName) {
|
|
216
|
+
throw new SyncError('Cannot destroy database - dbName not available')
|
|
217
|
+
}
|
|
218
|
+
return this.destroyDatabase(
|
|
219
|
+
database.adapter.dbName,
|
|
220
|
+
database.adapter.underlyingAdapter
|
|
221
|
+
)
|
|
222
|
+
} catch (err: unknown) {
|
|
223
|
+
this.telemetry.capture(err)
|
|
224
|
+
return database.write(() => database.unsafeResetDatabase())
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return database.write(() => database.unsafeResetDatabase()).then(() =>{
|
|
229
|
+
// destroy the db anyways
|
|
230
|
+
// useful if we're using user-namespaced dbs
|
|
231
|
+
if (this.destroyDatabase && database.adapter.dbName && database.adapter.underlyingAdapter) {
|
|
232
|
+
return this.destroyDatabase(
|
|
233
|
+
database.adapter.dbName,
|
|
234
|
+
database.adapter.underlyingAdapter
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
164
238
|
}
|
|
165
|
-
}
|
|
166
239
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
240
|
+
try {
|
|
241
|
+
Object.values(this.storesRegistry).forEach((store) => {
|
|
242
|
+
store.destroy()
|
|
243
|
+
})
|
|
171
244
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
245
|
+
this.runScope.abort()
|
|
246
|
+
this.strategyMap.forEach(({ strategies }) =>
|
|
247
|
+
strategies.forEach((strategy) => strategy.stop())
|
|
248
|
+
)
|
|
249
|
+
effectTeardowns.forEach((teardown) => teardown())
|
|
250
|
+
this.retry.stop()
|
|
251
|
+
this.context.stop()
|
|
252
|
+
|
|
253
|
+
contentProgressObserver.stop()
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// capture, but don't rethrow
|
|
256
|
+
this.telemetry.capture(error)
|
|
257
|
+
}
|
|
177
258
|
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
259
|
+
return clear(mode)
|
|
260
|
+
})().finally(() => {
|
|
261
|
+
this.teardownPromise = null
|
|
262
|
+
})
|
|
183
263
|
|
|
184
|
-
|
|
185
|
-
return clear(force);
|
|
186
|
-
} catch (error) {
|
|
187
|
-
if (!force) {
|
|
188
|
-
return clear(true);
|
|
189
|
-
}
|
|
190
|
-
throw error
|
|
191
|
-
}
|
|
264
|
+
return this.teardownPromise
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
return teardown
|
|
@@ -242,7 +242,7 @@ export default class SyncRepository<TModel extends BaseModel> {
|
|
|
242
242
|
return result
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
requestPushUnsynced() {
|
|
246
|
-
this.store.pushUnsyncedWithRetry()
|
|
245
|
+
requestPushUnsynced(cause?: string) {
|
|
246
|
+
this.store.pushUnsyncedWithRetry(undefined, { type: 'repo-push-request', cause })
|
|
247
247
|
}
|
|
248
248
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database, Q, Query, type Collection, type RecordId } from '@nozbe/watermelondb'
|
|
2
2
|
import { RawSerializer, ModelSerializer } from '../serializers'
|
|
3
|
-
import { ModelClass, SyncToken, SyncEntry,
|
|
3
|
+
import { ModelClass, SyncToken, SyncEntry, SyncUserScope, SyncContext, EpochMs } from '..'
|
|
4
4
|
import { SyncPullResponse, SyncPushResponse, SyncPullFetchFailureResponse, PushPayload, SyncStorePushResultSuccess, SyncStorePushResultFailure } from '../fetch'
|
|
5
5
|
import type SyncRetry from '../retry'
|
|
6
6
|
import type SyncRunScope from '../run-scope'
|
|
@@ -18,11 +18,13 @@ import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
|
18
18
|
import { SyncError } from '../errors'
|
|
19
19
|
|
|
20
20
|
type SyncPull = (
|
|
21
|
+
intendedUserId: number,
|
|
21
22
|
session: BaseSessionProvider,
|
|
22
23
|
previousFetchToken: SyncToken | null,
|
|
23
24
|
signal: AbortSignal
|
|
24
25
|
) => Promise<SyncPullResponse>
|
|
25
26
|
type SyncPush = (
|
|
27
|
+
intendedUserId: number,
|
|
26
28
|
session: BaseSessionProvider,
|
|
27
29
|
payload: PushPayload,
|
|
28
30
|
signal: AbortSignal
|
|
@@ -42,6 +44,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
42
44
|
static readonly CLEANUP_INTERVAL = 60_000 * 60 // 1hr
|
|
43
45
|
|
|
44
46
|
readonly telemetry: SyncTelemetry
|
|
47
|
+
readonly userScope: SyncUserScope
|
|
45
48
|
readonly context: SyncContext
|
|
46
49
|
readonly retry: SyncRetry
|
|
47
50
|
readonly runScope: SyncRunScope
|
|
@@ -67,12 +70,14 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
67
70
|
|
|
68
71
|
constructor(
|
|
69
72
|
{ model, comparator, pull, push }: SyncStoreConfig<TModel>,
|
|
73
|
+
userScope: SyncUserScope,
|
|
70
74
|
context: SyncContext,
|
|
71
75
|
db: Database,
|
|
72
76
|
retry: SyncRetry,
|
|
73
77
|
runScope: SyncRunScope,
|
|
74
78
|
telemetry: SyncTelemetry
|
|
75
79
|
) {
|
|
80
|
+
this.userScope = userScope
|
|
76
81
|
this.context = context
|
|
77
82
|
this.retry = retry
|
|
78
83
|
this.runScope = runScope
|
|
@@ -113,7 +118,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
113
118
|
let pushError: any = null
|
|
114
119
|
|
|
115
120
|
try {
|
|
116
|
-
await this.pushUnsyncedWithRetry(span)
|
|
121
|
+
await this.pushUnsyncedWithRetry(span, { type: 'sync-request', reason })
|
|
117
122
|
} catch (err) {
|
|
118
123
|
pushError = err
|
|
119
124
|
}
|
|
@@ -135,7 +140,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
135
140
|
this.telemetry.trace(
|
|
136
141
|
{ name: `sync:${this.model.table}`, op: 'push', attributes: { ...ctx, ...this.context.session.toJSON() } },
|
|
137
142
|
async span => {
|
|
138
|
-
await this.pushUnsyncedWithRetry(span)
|
|
143
|
+
await this.pushUnsyncedWithRetry(span, { type: 'push-request', reason })
|
|
139
144
|
}
|
|
140
145
|
)
|
|
141
146
|
}, { table: this.model.table, reason })
|
|
@@ -199,14 +204,14 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
199
204
|
|
|
200
205
|
async insertOne(builder: (record: TModel) => void, span?: Span) {
|
|
201
206
|
return await this.runScope.abortable(async () => {
|
|
202
|
-
const record = await this.
|
|
207
|
+
const record = await this.paranoidWrite(span, async () => {
|
|
203
208
|
return this.collection.create(rec => {
|
|
204
209
|
builder(rec)
|
|
205
210
|
})
|
|
206
211
|
})
|
|
207
212
|
this.emit('upserted', [record])
|
|
208
213
|
|
|
209
|
-
this.pushUnsyncedWithRetry(span)
|
|
214
|
+
this.pushUnsyncedWithRetry(span, { type: 'insertOne', recordId: record.id })
|
|
210
215
|
await this.ensurePersistence()
|
|
211
216
|
|
|
212
217
|
return this.modelSerializer.toPlainObject(record)
|
|
@@ -221,12 +226,12 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
221
226
|
throw new SyncError('Record not found', { id })
|
|
222
227
|
}
|
|
223
228
|
|
|
224
|
-
const record = await this.
|
|
229
|
+
const record = await this.paranoidWrite(span, async () => {
|
|
225
230
|
return found.update(builder)
|
|
226
231
|
})
|
|
227
232
|
this.emit('upserted', [record])
|
|
228
233
|
|
|
229
|
-
this.pushUnsyncedWithRetry(span)
|
|
234
|
+
this.pushUnsyncedWithRetry(span, { type: 'updateOneId', recordId: record.id })
|
|
230
235
|
await this.ensurePersistence()
|
|
231
236
|
|
|
232
237
|
return this.modelSerializer.toPlainObject(record)
|
|
@@ -239,7 +244,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
239
244
|
return await this.runScope.abortable(async () => {
|
|
240
245
|
const ids = Object.keys(builders)
|
|
241
246
|
|
|
242
|
-
const records = await this.
|
|
247
|
+
const records = await this.paranoidWrite(span, async writer => {
|
|
243
248
|
const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))))
|
|
244
249
|
const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
245
250
|
|
|
@@ -297,7 +302,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
297
302
|
this.emit('upserted', records)
|
|
298
303
|
|
|
299
304
|
if (!skipPush) {
|
|
300
|
-
this.pushUnsyncedWithRetry(span)
|
|
305
|
+
this.pushUnsyncedWithRetry(span, { type: 'upsertSome', recordIds: records.map(r => r.id).join(',') })
|
|
301
306
|
}
|
|
302
307
|
await this.ensurePersistence()
|
|
303
308
|
|
|
@@ -324,7 +329,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
324
329
|
return await this.runScope.abortable(async () => {
|
|
325
330
|
let record: TModel | null = null
|
|
326
331
|
|
|
327
|
-
await this.
|
|
332
|
+
await this.paranoidWrite(span, async writer => {
|
|
328
333
|
const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', id))).then(
|
|
329
334
|
(records) => records[0] || null
|
|
330
335
|
)
|
|
@@ -346,7 +351,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
346
351
|
this.emit('deleted', [id])
|
|
347
352
|
|
|
348
353
|
if (!skipPush) {
|
|
349
|
-
this.pushUnsyncedWithRetry(span)
|
|
354
|
+
this.pushUnsyncedWithRetry(span, { type: 'deleteOne', recordId: id })
|
|
350
355
|
}
|
|
351
356
|
await this.ensurePersistence()
|
|
352
357
|
|
|
@@ -356,7 +361,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
356
361
|
|
|
357
362
|
async deleteSome(ids: RecordId[], span?: Span, { skipPush = false } = {}) {
|
|
358
363
|
return this.runScope.abortable(async () => {
|
|
359
|
-
await this.
|
|
364
|
+
await this.paranoidWrite(span, async writer => {
|
|
360
365
|
const existing = await this.queryRecords(Q.where('id', Q.oneOf(ids)))
|
|
361
366
|
|
|
362
367
|
await writer.batch(...existing.map(record => record.prepareMarkAsDeleted()))
|
|
@@ -365,7 +370,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
365
370
|
this.emit('deleted', ids)
|
|
366
371
|
|
|
367
372
|
if (!skipPush) {
|
|
368
|
-
this.pushUnsyncedWithRetry(span)
|
|
373
|
+
this.pushUnsyncedWithRetry(span, { type: 'deleteSome', recordIds: ids.join(',') })
|
|
369
374
|
}
|
|
370
375
|
await this.ensurePersistence()
|
|
371
376
|
|
|
@@ -379,7 +384,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
379
384
|
|
|
380
385
|
async restoreSome(ids: RecordId[], span?: Span) {
|
|
381
386
|
return this.runScope.abortable(async () => {
|
|
382
|
-
const records = await this.
|
|
387
|
+
const records = await this.paranoidWrite(span, async writer => {
|
|
383
388
|
const records = await writer.callReader(() => this.queryMaybeDeletedRecords(
|
|
384
389
|
Q.where('id', Q.oneOf(ids)),
|
|
385
390
|
Q.where('_status', 'deleted')
|
|
@@ -401,7 +406,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
401
406
|
|
|
402
407
|
this.emit('upserted', records)
|
|
403
408
|
|
|
404
|
-
this.pushUnsyncedWithRetry(span)
|
|
409
|
+
this.pushUnsyncedWithRetry(span, { type: 'restoreSome', recordIds: ids.join(',') })
|
|
405
410
|
await this.ensurePersistence()
|
|
406
411
|
|
|
407
412
|
return records.map((record) => this.modelSerializer.toPlainObject(record))
|
|
@@ -410,7 +415,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
410
415
|
|
|
411
416
|
async importUpsert(recordRaws: TModel['_raw'][]) {
|
|
412
417
|
await this.runScope.abortable(async () => {
|
|
413
|
-
await this.
|
|
418
|
+
await this.paranoidWrite(undefined, async writer => {
|
|
414
419
|
const ids = recordRaws.map(r => r.id)
|
|
415
420
|
const existingMap = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))).then(records => {
|
|
416
421
|
return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
@@ -469,7 +474,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
469
474
|
}
|
|
470
475
|
async importDeletion(ids: RecordId[]) {
|
|
471
476
|
await this.runScope.abortable(async () => {
|
|
472
|
-
await this.
|
|
477
|
+
await this.paranoidWrite(undefined, async writer => {
|
|
473
478
|
const existingMap = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))).then(records => {
|
|
474
479
|
return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
475
480
|
})
|
|
@@ -516,7 +521,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
516
521
|
)()
|
|
517
522
|
}
|
|
518
523
|
|
|
519
|
-
public async pushUnsyncedWithRetry(span?: Span) {
|
|
524
|
+
public async pushUnsyncedWithRetry(span?: Span, cause?: string | Record<string, string>) {
|
|
520
525
|
const records = await this.queryMaybeDeletedRecords(Q.where('_status', Q.notEq('synced')))
|
|
521
526
|
|
|
522
527
|
if (records.length) {
|
|
@@ -529,8 +534,9 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
529
534
|
this.pushCoalescer.push(
|
|
530
535
|
records,
|
|
531
536
|
queueThrottle({ state: this.pushThrottleState }, () => {
|
|
537
|
+
const causeAttrs = typeof cause === 'string' ? { type: cause } : cause ?? {}
|
|
532
538
|
return this.retry.request<SyncPushResponse>(
|
|
533
|
-
{ name: `push:${this.model.table}`, op: 'push', parentSpan: span },
|
|
539
|
+
{ name: `push:${this.model.table}`, op: 'push', parentSpan: span, attributes: { ...causeAttrs } },
|
|
534
540
|
async (span) => {
|
|
535
541
|
// re-query records since this fn may be deferred due to throttling/retries
|
|
536
542
|
const currentRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(recordIds)))
|
|
@@ -571,10 +577,21 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
571
577
|
attributes: { lastFetchToken: lastFetchToken ?? undefined, ...this.context.session.toJSON() },
|
|
572
578
|
parentSpan: pullSpan,
|
|
573
579
|
},
|
|
574
|
-
() => this.puller(this.context.session, lastFetchToken, this.runScope.signal)
|
|
580
|
+
() => this.puller(this.userScope.initialId, this.context.session, lastFetchToken, this.runScope.signal)
|
|
575
581
|
)
|
|
576
582
|
|
|
577
583
|
if (response.ok) {
|
|
584
|
+
const initialId = this.userScope.initialId
|
|
585
|
+
const currentId = this.userScope.getCurrentId()
|
|
586
|
+
|
|
587
|
+
if (response.intendedUserId !== initialId || response.intendedUserId !== currentId) {
|
|
588
|
+
throw new SyncError('Intended user ID does not match', {
|
|
589
|
+
intendedUserId: response.intendedUserId,
|
|
590
|
+
initialUserId: initialId,
|
|
591
|
+
currentUserId: currentId
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
578
595
|
await this.writeEntries(response.entries, !response.previousToken, pullSpan)
|
|
579
596
|
await this.setLastFetchToken(response.token)
|
|
580
597
|
|
|
@@ -604,7 +621,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
604
621
|
attributes: { ...this.context.session.toJSON() },
|
|
605
622
|
parentSpan: pushSpan,
|
|
606
623
|
},
|
|
607
|
-
() => this.pusher(this.context.session, payload, this.runScope.signal)
|
|
624
|
+
() => this.pusher(this.userScope.initialId, this.context.session, payload, this.runScope.signal)
|
|
608
625
|
)
|
|
609
626
|
|
|
610
627
|
if (response.ok) {
|
|
@@ -620,7 +637,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
620
637
|
if (failedResults.length) {
|
|
621
638
|
this.telemetry.warn(
|
|
622
639
|
`[store:${this.model.table}] Push completed with failed records`,
|
|
623
|
-
{ results: failedResults }
|
|
640
|
+
{ results: failedResults, resultsJSON: JSON.stringify(failedResults) }
|
|
624
641
|
)
|
|
625
642
|
}
|
|
626
643
|
|
|
@@ -775,10 +792,22 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
775
792
|
})
|
|
776
793
|
}
|
|
777
794
|
|
|
778
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Performs telemetry-wrapped write, crucially checking if the user has changed
|
|
797
|
+
*/
|
|
798
|
+
private paranoidWrite<T>(
|
|
779
799
|
parentSpan: Span | undefined,
|
|
780
800
|
work: (writer: WriterInterface) => Promise<T>
|
|
781
801
|
): Promise<T> {
|
|
802
|
+
const initialId = this.userScope.initialId
|
|
803
|
+
const currentId = this.userScope.getCurrentId()
|
|
804
|
+
if (initialId !== currentId) {
|
|
805
|
+
throw new SyncError('Aborted cross-user write operation', {
|
|
806
|
+
initialId,
|
|
807
|
+
currentId,
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
|
|
782
811
|
return this.telemetry.trace(
|
|
783
812
|
{ name: `write:${this.model.table}`, op: 'write', parentSpan, attributes: { ...this.context.session.toJSON() } },
|
|
784
813
|
(writeSpan) => {
|
|
@@ -799,7 +828,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
799
828
|
|
|
800
829
|
private async writeEntries(entries: SyncEntry[], freshSync: boolean = false, parentSpan?: Span) {
|
|
801
830
|
await this.runScope.abortable(async () => {
|
|
802
|
-
return this.
|
|
831
|
+
return this.paranoidWrite(parentSpan, async (writer) => {
|
|
803
832
|
const batches = await this.buildWriteBatchesFromEntries(writer, entries, freshSync)
|
|
804
833
|
|
|
805
834
|
for (const batch of batches) {
|
|
@@ -945,7 +974,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
945
974
|
private startCleanupTimer() {
|
|
946
975
|
this.cleanupTimer = setInterval(() => {
|
|
947
976
|
this.runScope.abortable(async () => {
|
|
948
|
-
this.
|
|
977
|
+
this.paranoidWrite(undefined, async (writer) => {
|
|
949
978
|
await this.cleanupOldDeletedRecords(writer)
|
|
950
979
|
})
|
|
951
980
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SyncUserScope } from '../index'
|
|
1
2
|
import watermelonLogger from '@nozbe/watermelondb/utils/common/logger'
|
|
2
3
|
import { SyncError, SyncUnexpectedError } from '../errors'
|
|
3
4
|
|
|
@@ -6,6 +7,7 @@ export type SentryBrowserOptions = NonNullable<Parameters<typeof InjectedSentry.
|
|
|
6
7
|
|
|
7
8
|
export type SentryLike = {
|
|
8
9
|
captureException: typeof InjectedSentry.captureException
|
|
10
|
+
captureMessage: typeof InjectedSentry.captureMessage
|
|
9
11
|
addBreadcrumb: typeof InjectedSentry.addBreadcrumb
|
|
10
12
|
startSpan: typeof InjectedSentry.startSpan
|
|
11
13
|
}
|
|
@@ -24,6 +26,15 @@ export enum SeverityLevel {
|
|
|
24
26
|
FATAL = 5,
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
const severityLevelToSentryLevel: Record<SeverityLevel, 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'> = {
|
|
30
|
+
[SeverityLevel.DEBUG]: 'debug',
|
|
31
|
+
[SeverityLevel.INFO]: 'info',
|
|
32
|
+
[SeverityLevel.LOG]: 'log',
|
|
33
|
+
[SeverityLevel.WARNING]: 'warning',
|
|
34
|
+
[SeverityLevel.ERROR]: 'error',
|
|
35
|
+
[SeverityLevel.FATAL]: 'fatal',
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
export class SyncTelemetry {
|
|
28
39
|
private static instance: SyncTelemetry | null = null
|
|
29
40
|
|
|
@@ -40,7 +51,7 @@ export class SyncTelemetry {
|
|
|
40
51
|
SyncTelemetry.instance = null
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
private
|
|
54
|
+
private userScope: SyncUserScope
|
|
44
55
|
private Sentry: SentryLike
|
|
45
56
|
private level: SeverityLevel
|
|
46
57
|
private pretty: boolean
|
|
@@ -51,14 +62,14 @@ export class SyncTelemetry {
|
|
|
51
62
|
private _ignoreConsole = false
|
|
52
63
|
|
|
53
64
|
constructor(
|
|
54
|
-
|
|
65
|
+
userScope: SyncUserScope,
|
|
55
66
|
{
|
|
56
67
|
Sentry,
|
|
57
68
|
level,
|
|
58
69
|
pretty,
|
|
59
70
|
}: { Sentry: SentryLike; level?: keyof typeof SeverityLevel; pretty?: boolean }
|
|
60
71
|
) {
|
|
61
|
-
this.
|
|
72
|
+
this.userScope = userScope
|
|
62
73
|
this.Sentry = Sentry
|
|
63
74
|
this.level =
|
|
64
75
|
typeof level !== 'undefined' && level in SeverityLevel
|
|
@@ -78,7 +89,8 @@ export class SyncTelemetry {
|
|
|
78
89
|
op: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.op}`,
|
|
79
90
|
attributes: {
|
|
80
91
|
...opts.attributes,
|
|
81
|
-
|
|
92
|
+
'user.initialId': this.userScope.initialId,
|
|
93
|
+
'user.currentId': this.userScope.getCurrentId(),
|
|
82
94
|
},
|
|
83
95
|
}
|
|
84
96
|
return this.Sentry.startSpan<T>(options, (span) => {
|
|
@@ -93,7 +105,7 @@ export class SyncTelemetry {
|
|
|
93
105
|
})
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
capture(err:
|
|
108
|
+
capture(err: unknown, context = {}) {
|
|
97
109
|
const wrapped =
|
|
98
110
|
err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context)
|
|
99
111
|
|
|
@@ -110,7 +122,7 @@ export class SyncTelemetry {
|
|
|
110
122
|
)
|
|
111
123
|
|
|
112
124
|
this._ignoreConsole = true
|
|
113
|
-
this.error(err.message)
|
|
125
|
+
this.error(err instanceof Error ? err.message : String(err))
|
|
114
126
|
this._ignoreConsole = false
|
|
115
127
|
}
|
|
116
128
|
|
|
@@ -155,11 +167,11 @@ export class SyncTelemetry {
|
|
|
155
167
|
this._log(SeverityLevel.WARNING, 'warn', message, extra)
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
error(message: unknown
|
|
170
|
+
error(message: unknown, extra?: any) {
|
|
159
171
|
this._log(SeverityLevel.ERROR, 'error', message, extra)
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
fatal(message: unknown
|
|
174
|
+
fatal(message: unknown, extra?: any) {
|
|
163
175
|
this._log(SeverityLevel.FATAL, 'error', message, extra)
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -169,7 +181,12 @@ export class SyncTelemetry {
|
|
|
169
181
|
this._ignoreConsole = true
|
|
170
182
|
console[consoleMethod](...this.formattedConsoleMessage(message, extra))
|
|
171
183
|
this._ignoreConsole = false
|
|
172
|
-
|
|
184
|
+
|
|
185
|
+
if (level >= SeverityLevel.WARNING) {
|
|
186
|
+
this.Sentry.captureMessage(message instanceof Error ? message.message : String(message), severityLevelToSentryLevel[level])
|
|
187
|
+
} else {
|
|
188
|
+
this.Sentry.addBreadcrumb({ message: message instanceof Error ? message.message : String(message), level: severityLevelToSentryLevel[level] })
|
|
189
|
+
}
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
private formattedConsoleMessage(message: unknown, extra: any) {
|