musora-content-services 2.107.5 → 2.107.7

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 CHANGED
@@ -2,6 +2,20 @@
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.107.7](https://github.com/railroadmedia/musora-content-services/compare/v2.107.6...v2.107.7) (2025-12-29)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * daniel merged bad ([#675](https://github.com/railroadmedia/musora-content-services/issues/675)) ([1c5863b](https://github.com/railroadmedia/musora-content-services/commit/1c5863bc921967fe16875367d9589c8dcf4e0ac3))
11
+
12
+ ### [2.107.6](https://github.com/railroadmedia/musora-content-services/compare/v2.107.5...v2.107.6) (2025-12-29)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **TP-1051:** group contentProgress upsert pushes ([#665](https://github.com/railroadmedia/musora-content-services/issues/665)) ([27ff11f](https://github.com/railroadmedia/musora-content-services/commit/27ff11fb1b10075313e614cf895356a221f0c0d1))
18
+
5
19
  ### [2.107.5](https://github.com/railroadmedia/musora-content-services/compare/v2.107.4...v2.107.5) (2025-12-29)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.107.5",
3
+ "version": "2.107.7",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  getEnrichedLearningPaths,
55
55
  getLearningPathLessonsByIds,
56
56
  mapContentToParent,
57
- onContentCompletedLearningPathListener,
57
+ onContentCompletedLearningPathActions,
58
58
  resetAllLearningPaths,
59
59
  startLearningPath,
60
60
  updateDailySession
@@ -202,9 +202,7 @@ import {
202
202
  } from './services/liveTesting.ts';
203
203
 
204
204
  import {
205
- emitContentCompleted,
206
205
  emitProgressSaved,
207
- onContentCompleted,
208
206
  onProgressSaved
209
207
  } from './services/progress-events.js';
210
208
 
@@ -484,7 +482,6 @@ declare module 'musora-content-services' {
484
482
  deleteUserActivity,
485
483
  duplicatePlaylist,
486
484
  editComment,
487
- emitContentCompleted,
488
485
  emitProgressSaved,
489
486
  enrollUserInGuidedCourse,
490
487
  extractSanityUrl,
@@ -675,8 +672,7 @@ declare module 'musora-content-services' {
675
672
  markNotificationAsUnread,
676
673
  markThreadAsRead,
677
674
  numberOfActiveUsers,
678
- onContentCompleted,
679
- onContentCompletedLearningPathListener,
675
+ onContentCompletedLearningPathActions,
680
676
  onProgressSaved,
681
677
  openComment,
682
678
  otherStats,
package/src/index.js CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  getEnrichedLearningPaths,
59
59
  getLearningPathLessonsByIds,
60
60
  mapContentToParent,
61
- onContentCompletedLearningPathListener,
61
+ onContentCompletedLearningPathActions,
62
62
  resetAllLearningPaths,
63
63
  startLearningPath,
64
64
  updateDailySession
@@ -206,9 +206,7 @@ import {
206
206
  } from './services/liveTesting.ts';
207
207
 
208
208
  import {
209
- emitContentCompleted,
210
209
  emitProgressSaved,
211
- onContentCompleted,
212
210
  onProgressSaved
213
211
  } from './services/progress-events.js';
214
212
 
@@ -483,7 +481,6 @@ export {
483
481
  deleteUserActivity,
484
482
  duplicatePlaylist,
485
483
  editComment,
486
- emitContentCompleted,
487
484
  emitProgressSaved,
488
485
  enrollUserInGuidedCourse,
489
486
  extractSanityUrl,
@@ -674,8 +671,7 @@ export {
674
671
  markNotificationAsUnread,
675
672
  markThreadAsRead,
676
673
  numberOfActiveUsers,
677
- onContentCompleted,
678
- onContentCompletedLearningPathListener,
674
+ onContentCompletedLearningPathActions,
679
675
  onProgressSaved,
680
676
  openComment,
681
677
  otherStats,
@@ -445,11 +445,11 @@ async function resetIfPossible(contentId: number, collection: CollectionParamete
445
445
  return status !== '' ? await contentStatusReset(contentId, collection) : null
446
446
  }
447
447
 
448
- export async function onContentCompletedLearningPathListener(event) {
449
- if (event?.collection?.type !== COLLECTION_TYPE.LEARNING_PATH) return
450
- if (event.contentId !== event?.collection?.id) return
448
+ export async function onContentCompletedLearningPathActions(contentId: number, collection: CollectionObject|null) {
449
+ if (collection?.type !== COLLECTION_TYPE.LEARNING_PATH) return
450
+ if (contentId !== collection?.id) return
451
451
 
452
- const learningPathId = event.contentId
452
+ const learningPathId = contentId
453
453
  const learningPath = await getEnrichedLearningPath(learningPathId)
454
454
 
455
455
  const brand = learningPath.brand
@@ -470,5 +470,5 @@ export async function onContentCompletedLearningPathListener(event) {
470
470
  await startLearningPath(brand, nextLearningPath.id)
471
471
  const nextLearningPathData = await getEnrichedLearningPath(nextLearningPath.id)
472
472
 
473
- await contentStatusReset(nextLearningPathData.intro_video.id)
473
+ await contentStatusReset(nextLearningPathData.intro_video.id, {skipPush: true})
474
474
  }
@@ -3,8 +3,7 @@ import { db } from './sync'
3
3
  import { COLLECTION_TYPE, STATE } from './sync/models/ContentProgress'
4
4
  import { trackUserPractice, findIncompleteLesson } from './userActivity'
5
5
  import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
6
- import { emitContentCompleted } from './progress-events'
7
- import {getDailySession} from "./content-org/learning-paths.ts";
6
+ import {getDailySession, onContentCompletedLearningPathActions} from "./content-org/learning-paths.ts";
8
7
  import {getToday} from "./dateUtils.js";
9
8
  import { fetchBrandsByContentIds } from './sanity.js'
10
9
 
@@ -464,11 +463,12 @@ export async function contentStatusStarted(contentId, collection = null) {
464
463
  false
465
464
  )
466
465
  }
467
- export async function contentStatusReset(contentId, collection = null) {
468
- return resetStatus(contentId, collection)
466
+ export async function contentStatusReset(contentId, collection = null, {skipPush = false} = {}) {
467
+ return resetStatus(contentId, collection, {skipPush})
469
468
  }
470
469
 
471
- async function saveContentProgress(contentId, collection, progress, currentSeconds) {
470
+ async function saveContentProgress(contentId, collection, progress, currentSeconds, {skipPush = false} = {}) {
471
+ const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
472
472
 
473
473
  // filter out contentIds that are setting progress lower than existing
474
474
  const contentIdProgress = await getProgressDataByIds([contentId], collection)
@@ -480,10 +480,9 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
480
480
  contentId,
481
481
  collection,
482
482
  progress,
483
- currentSeconds
483
+ currentSeconds,
484
+ {skipPush: true}
484
485
  )
485
- if (progress === 100) emitContentCompleted(contentId, collection)
486
-
487
486
  // note - previous implementation explicitly did not trickle progress to children here
488
487
  // (only to siblings/parents via le bubbles)
489
488
 
@@ -500,27 +499,32 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
500
499
  }
501
500
 
502
501
  // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
503
- await db.contentProgress.recordProgressMany(bubbledProgresses, collection, collection?.type !== COLLECTION_TYPE.LEARNING_PATH)
502
+ await db.contentProgress.recordProgressMany(bubbledProgresses, collection, {tentative: !isLP, skipPush: true})
504
503
 
505
- if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
504
+ if (isLP) {
506
505
  let exportIds = bubbledProgresses
507
506
  exportIds[contentId] = progress
508
- await duplicateLearningPathProgressToExternalContents(exportIds, collection, hierarchy)
507
+ await duplicateLearningPathProgressToExternalContents(exportIds, collection, hierarchy, {skipPush: true})
509
508
  }
510
509
 
510
+ if (progress === 100) await onContentCompletedLearningPathActions(contentId, collection)
511
+
511
512
  for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
512
513
  if (bubbledProgress === 100) {
513
- emitContentCompleted(Number(bubbledContentId), collection)
514
+ await onContentCompletedLearningPathActions(Number(bubbledContentId), collection)
514
515
  }
515
516
  }
517
+
518
+ if (!skipPush) db.contentProgress.requestPushUnsynced()
519
+
516
520
  return response
517
521
  }
518
522
 
519
- async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
520
- const progress = isCompleted ? 100 : 0
521
- const response = await db.contentProgress.recordProgress(contentId, collection, progress)
523
+ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {skipPush = false} = {}) {
524
+ const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
522
525
 
523
- if (progress === 100) emitContentCompleted(contentId, collection)
526
+ const progress = isCompleted ? 100 : 0
527
+ const response = await db.contentProgress.recordProgress(contentId, collection, progress, null, {skipPush: true})
524
528
 
525
529
  const hierarchy = await getHierarchy(contentId, collection)
526
530
 
@@ -529,29 +533,33 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted) {
529
533
  ...await bubbleProgress(hierarchy, contentId, collection)
530
534
  }
531
535
  // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
532
- await db.contentProgress.recordProgressMany(progresses, collection, collection?.type !== COLLECTION_TYPE.LEARNING_PATH)
536
+ await db.contentProgress.recordProgressMany(progresses, collection, {tentative: !isLP, skipPush: true})
537
+ if (isLP) {
533
538
 
534
- if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
535
539
  let exportProgresses = progresses
536
540
  exportProgresses[contentId] = progress
537
- await duplicateLearningPathProgressToExternalContents(exportProgresses, collection, hierarchy)
541
+ await duplicateLearningPathProgressToExternalContents(exportProgresses, collection, hierarchy, {skipPush: true})
538
542
  }
539
543
 
544
+ if (progress === 100) await onContentCompletedLearningPathActions(contentId, collection)
545
+
540
546
  for (const [id, progress] of Object.entries(progresses)) {
541
547
  if (progress === 100) {
542
- emitContentCompleted(Number(id), collection)
548
+ await onContentCompletedLearningPathActions(Number(id), collection)
543
549
  }
544
550
  }
545
551
 
552
+ if (!skipPush) db.contentProgress.requestPushUnsynced()
553
+
546
554
  return response
547
555
  }
548
556
 
549
557
  // we cannot simply pass LP id with self collection, because we do not have a-la-carte LP's set up yet,
550
558
  // and we need each lesson to bubble to its parent outside of LP
551
- async function duplicateLearningPathProgressToExternalContents(ids, collection, hierarchy) {
559
+ async function duplicateLearningPathProgressToExternalContents(ids, collection, hierarchy, {skipPush = false} = {}) {
552
560
  // filter out LPs. we dont want to duplicate to LP's while we dont have a-la-cart LP's set up.
553
561
  let filteredIds = Object.fromEntries(
554
- Object.entries(ids).filter((id) => {
562
+ Object.entries(ids).filter(([id]) => {
555
563
  return hierarchy.parents[parseInt(id)] !== null
556
564
  })
557
565
  )
@@ -567,8 +575,13 @@ async function duplicateLearningPathProgressToExternalContents(ids, collection,
567
575
  })
568
576
 
569
577
  // each handles its own bubbling.
570
- filteredIds.forEach(([id, pct]) => {
571
- saveContentProgress(parseInt(id), null, pct)
578
+ // skipPush on all but last to avoid multiple push requests
579
+ filteredIds.forEach(([id, pct], index) => {
580
+ if (index === filteredIds.length - 1) {
581
+ saveContentProgress(parseInt(id), null, pct, null, {skipPush})
582
+ } else {
583
+ saveContentProgress(parseInt(id), null, pct, null, {skipPush: true})
584
+ }
572
585
  })
573
586
  }
574
587
 
@@ -580,10 +593,18 @@ async function getHierarchy(contentId, collection) {
580
593
  }
581
594
  }
582
595
 
583
- async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted) {
596
+ async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, {skipPush = false} = {}) {
597
+ const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
584
598
  const progress = isCompleted ? 100 : 0
599
+
600
+ if (progress === 100) {
601
+ for (const contentId of contentIds) {
602
+ await onContentCompletedLearningPathActions(contentId, collection)
603
+ }
604
+ }
605
+
585
606
  const contents = Object.fromEntries(contentIds.map((id) => [id, progress]))
586
- const response = await db.contentProgress.recordProgressMany(contents, collection, true)
607
+ const response = await db.contentProgress.recordProgressMany(contents, collection, {tentative: !isLP, skipPush: true})
587
608
 
588
609
  // we assume this is used only for contents within the same hierarchy
589
610
  const hierarchy = await getHierarchy(collection.id, collection)
@@ -597,22 +618,32 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
597
618
  }
598
619
  }
599
620
  // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
600
- await db.contentProgress.recordProgressMany(progresses, collection, collection?.type !== COLLECTION_TYPE.LEARNING_PATH)
621
+ await db.contentProgress.recordProgressMany(progresses, collection, {tentative: !isLP, skipPush: true})
601
622
 
602
- if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
623
+ if (isLP) {
603
624
  let exportProgresses = progresses
604
625
  for (const contentId of contentIds){
605
626
  exportProgresses[contentId] = progress
606
627
  }
607
- await duplicateLearningPathProgressToExternalContents(exportProgresses, collection, hierarchy)
628
+ await duplicateLearningPathProgressToExternalContents(exportProgresses, collection, hierarchy, {skipPush: true})
608
629
  }
609
630
 
631
+ for (const [id, progress] of Object.entries(progresses)) {
632
+ if (progress === 100) {
633
+ await onContentCompletedLearningPathActions(Number(id), collection)
634
+ }
635
+ }
636
+
637
+ if (!skipPush) db.contentProgress.requestPushUnsynced()
638
+
610
639
  return response
611
640
  }
612
641
 
613
- async function resetStatus(contentId, collection = null) {
642
+ async function resetStatus(contentId, collection = null, {skipPush = false} = {}) {
643
+ const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
644
+
614
645
  const progress = 0
615
- const response = await db.contentProgress.eraseProgress(contentId, collection)
646
+ const response = await db.contentProgress.eraseProgress(contentId, collection, {skipPush: true})
616
647
  const hierarchy = await getHierarchy(contentId, collection)
617
648
 
618
649
  let progresses = {
@@ -620,13 +651,15 @@ async function resetStatus(contentId, collection = null) {
620
651
  ...await bubbleProgress(hierarchy, contentId, collection)
621
652
  }
622
653
  // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
623
- await db.contentProgress.recordProgressMany(progresses, collection, collection?.type !== COLLECTION_TYPE.LEARNING_PATH)
654
+ await db.contentProgress.recordProgressMany(progresses, collection, {tentative: !isLP, skipPush: true})
624
655
 
625
- if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
656
+ if (isLP) {
626
657
  progresses[contentId] = progress
627
- await duplicateLearningPathProgressToExternalContents(progresses, collection, hierarchy)
658
+ await duplicateLearningPathProgressToExternalContents(progresses, collection, hierarchy, {skipPush: true})
628
659
  }
629
660
 
661
+ if (!skipPush) db.contentProgress.requestPushUnsynced()
662
+
630
663
  return response
631
664
  }
632
665
 
@@ -56,55 +56,3 @@ export function emitProgressSaved(event) {
56
56
  }
57
57
  })
58
58
  }
59
-
60
- /**
61
- * @typedef {Object} ContentCompletedEvent
62
- * @property {number} contentId - Railcontent ID of the completed content item
63
- * @property {Object|null} collection - Collection context information
64
- * @property {string} collection.type - Collection type (learning-path, guided-course, etc.)
65
- * @property {number} collection.id - Collection ID
66
- */
67
-
68
- /**
69
- * @callback ContentCompletedListener
70
- * @param {ContentCompletedEvent} event - The content completion event data
71
- * @returns {void}
72
- */
73
- const completedListeners = new Set()
74
-
75
- /**
76
- * @param {ContentCompletedListener} listener - Function called when content is completed
77
- * @returns {function(): void} Cleanup function to unregister the listener
78
- *
79
- * @example Listen for content completion
80
- * const cleanup = onContentCompleted((event) => {
81
- * console.log(`Content ${event.contentId} completed!`)
82
- * if (event.collection) {
83
- * console.log(`Within ${event.collection.type}: ${event.collection.id}`)
84
- * checkCollectionProgress(event.collection.id)
85
- * }
86
- * })
87
- *
88
- * // Later, when no longer needed:
89
- * cleanup()
90
- */
91
- export function onContentCompleted(listener) {
92
- completedListeners.add(listener)
93
- return () => completedListeners.delete(listener)
94
- }
95
-
96
- /**
97
- * @param {number} contentId - The ID of the completed content item
98
- * @param {Object|null} collection - Collection context information
99
- * @returns {void}
100
- */
101
- export function emitContentCompleted(contentId, collection) {
102
- const event = { contentId: contentId, collection: collection }
103
- completedListeners.forEach((listener) => {
104
- try {
105
- listener(event)
106
- } catch (error) {
107
- console.error('Error in contentCompleted listener:', error)
108
- }
109
- })
110
- }
@@ -14,9 +14,6 @@ import { inBoundary } from './errors/boundary'
14
14
  import createStoresFromConfig from './store-configs'
15
15
  import { contentProgressObserver } from '../awards/internal/content-progress-observer'
16
16
 
17
- import { onProgressSaved, onContentCompleted } from '../progress-events'
18
- import { onContentCompletedLearningPathListener } from '../content-org/learning-paths'
19
-
20
17
  export default class SyncManager {
21
18
  private static counter = 0
22
19
  private static instance: SyncManager | null = null
@@ -137,7 +134,6 @@ export default class SyncManager {
137
134
  contentProgressObserver.start(this.database).catch((error) => {
138
135
  this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
139
136
  })
140
- onContentCompleted(onContentCompletedLearningPathListener)
141
137
 
142
138
  const teardown = async () => {
143
139
  this.telemetry.debug('[SyncManager] Tearing down')
@@ -96,10 +96,10 @@ export default class SyncRepository<TModel extends BaseModel> {
96
96
  )
97
97
  }
98
98
 
99
- protected async upsertOne(id: RecordId, builder: (record: TModel) => void) {
99
+ protected async upsertOne(id: RecordId, builder: (record: TModel) => void, { skipPush = false } = {}) {
100
100
  return this.store.telemetry.trace(
101
101
  { name: `upsertOne:${this.store.model.table}`, op: 'upsert' },
102
- (span) => this._respondToWrite(() => this.store.upsertOne(id, builder, span), span)
102
+ (span) => this._respondToWrite(() => this.store.upsertOne(id, builder, span, {skipPush}), span)
103
103
  )
104
104
  }
105
105
 
@@ -110,24 +110,24 @@ export default class SyncRepository<TModel extends BaseModel> {
110
110
  )
111
111
  }
112
112
 
113
- protected async upsertSome(builders: Record<RecordId, (record: TModel) => void>) {
113
+ protected async upsertSome(builders: Record<RecordId, (record: TModel) => void>, { skipPush = false } = {}) {
114
114
  return this.store.telemetry.trace(
115
115
  { name: `upsertSome:${this.store.model.table}`, op: 'upsert' },
116
- (span) => this._respondToWrite(() => this.store.upsertSome(builders, span), span)
116
+ (span) => this._respondToWrite(() => this.store.upsertSome(builders, span, {skipPush}), span)
117
117
  )
118
118
  }
119
119
 
120
- protected async upsertSomeTentative(builders: Record<RecordId, (record: TModel) => void>) {
120
+ protected async upsertSomeTentative(builders: Record<RecordId, (record: TModel) => void>, { skipPush = false } = {}) {
121
121
  return this.store.telemetry.trace(
122
122
  { name: `upsertSomeTentative:${this.store.model.table}`, op: 'upsert' },
123
- (span) => this._respondToWrite(() => this.store.upsertSomeTentative(builders, span), span)
123
+ (span) => this._respondToWrite(() => this.store.upsertSomeTentative(builders, span, {skipPush}), span)
124
124
  )
125
125
  }
126
126
 
127
- protected async deleteOne(id: RecordId) {
127
+ protected async deleteOne(id: RecordId, { skipPush = false } = {}) {
128
128
  return this.store.telemetry.trace(
129
129
  { name: `delete:${this.store.model.table}`, op: 'delete' },
130
- (span) => this._respondToWriteIds(() => this.store.deleteOne(id, span), span)
130
+ (span) => this._respondToWriteIds(() => this.store.deleteOne(id, span, {skipPush}), span)
131
131
  )
132
132
  }
133
133
 
@@ -244,4 +244,8 @@ export default class SyncRepository<TModel extends BaseModel> {
244
244
  }
245
245
  return result
246
246
  }
247
+
248
+ protected async _requestPushUnsynced() {
249
+ await this.store.pushUnsyncedWithRetry()
250
+ }
247
251
  }
@@ -133,7 +133,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
133
133
  }
134
134
  }
135
135
 
136
- recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number) {
136
+ recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false} = {}) {
137
137
  const id = ProgressRepository.generateId(contentId, collection)
138
138
 
139
139
  const result = this.upsertOne(id, (r) => {
@@ -146,7 +146,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
146
146
  if (typeof resumeTime != 'undefined') {
147
147
  r.resume_time_seconds = Math.floor(resumeTime)
148
148
  }
149
- })
149
+ }, { skipPush })
150
150
 
151
151
  // Emit event AFTER database write completes
152
152
  result.then(() => {
@@ -176,7 +176,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
176
176
  recordProgressMany(
177
177
  contentProgresses: Record<string, number>, // Accept plain object
178
178
  collection: CollectionParameter | null,
179
- tentative: boolean
179
+ { tentative = true, skipPush = false }: { tentative?: boolean; skipPush?: boolean } = {}
180
180
  ) {
181
181
 
182
182
  const data = Object.fromEntries(
@@ -192,14 +192,18 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
192
192
  ])
193
193
  )
194
194
  return tentative
195
- ? this.upsertSomeTentative(data)
196
- : this.upsertSome(data)
195
+ ? this.upsertSomeTentative(data, { skipPush })
196
+ : this.upsertSome(data, { skipPush })
197
197
 
198
198
  //todo add event emitting for bulk updates?
199
199
  }
200
200
 
201
- eraseProgress(contentId: number, collection: CollectionParameter | null) {
202
- return this.deleteOne(ProgressRepository.generateId(contentId, collection))
201
+ eraseProgress(contentId: number, collection: CollectionParameter | null, {skipPush = false} = {}) {
202
+ return this.deleteOne(ProgressRepository.generateId(contentId, collection), { skipPush })
203
+ }
204
+
205
+ async requestPushUnsynced() {
206
+ await this._requestPushUnsynced()
203
207
  }
204
208
 
205
209
  private static generateId(
@@ -235,7 +235,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
235
235
  })
236
236
  }
237
237
 
238
- async upsertSome(builders: Record<RecordId, (record: TModel) => void>, span?: Span) {
238
+ async upsertSome(builders: Record<RecordId, (record: TModel) => void>, span?: Span, { skipPush = false } = {}) {
239
239
  if (Object.keys(builders).length === 0) return []
240
240
 
241
241
  return await this.runScope.abortable(async () => {
@@ -298,29 +298,31 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
298
298
 
299
299
  this.emit('upserted', records)
300
300
 
301
- this.pushUnsyncedWithRetry(span)
301
+ if (!skipPush) {
302
+ this.pushUnsyncedWithRetry(span)
303
+ }
302
304
  await this.ensurePersistence()
303
305
 
304
306
  return records.map((record) => this.modelSerializer.toPlainObject(record))
305
307
  })
306
308
  }
307
309
 
308
- async upsertSomeTentative(builders: Record<RecordId, (record: TModel) => void>, span?: Span) {
310
+ async upsertSomeTentative(builders: Record<RecordId, (record: TModel) => void>, span?: Span, { skipPush = false } = {}) {
309
311
  return this.upsertSome(Object.fromEntries(Object.entries(builders).map(([id, builder]) => [id, record => {
310
312
  builder(record)
311
313
  record._raw._status = 'synced'
312
- }])), span)
314
+ }])), span, {skipPush})
313
315
  }
314
316
 
315
- async upsertOne(id: RecordId, builder: (record: TModel) => void, span?: Span) {
316
- return this.upsertSome({ [id]: builder }, span).then(r => r[0])
317
+ async upsertOne(id: RecordId, builder: (record: TModel) => void, span?: Span, { skipPush = false } = {}) {
318
+ return this.upsertSome({ [id]: builder }, span, {skipPush}).then(r => r[0])
317
319
  }
318
320
 
319
321
  async upsertOneTentative(id: string, builder: (record: TModel) => void, span?: Span) {
320
322
  return this.upsertSomeTentative({ [id]: builder }, span).then(r => r[0])
321
323
  }
322
324
 
323
- async deleteOne(id: RecordId, span?: Span) {
325
+ async deleteOne(id: RecordId, span?: Span, { skipPush = false } = {}) {
324
326
  return await this.runScope.abortable(async () => {
325
327
  let record: TModel | null = null
326
328
 
@@ -345,7 +347,9 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
345
347
 
346
348
  this.emit('deleted', [id])
347
349
 
348
- this.pushUnsyncedWithRetry(span)
350
+ if (!skipPush) {
351
+ this.pushUnsyncedWithRetry(span)
352
+ }
349
353
  await this.ensurePersistence()
350
354
 
351
355
  return id
@@ -477,7 +481,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
477
481
  )()
478
482
  }
479
483
 
480
- private async pushUnsyncedWithRetry(span?: Span) {
484
+ public async pushUnsyncedWithRetry(span?: Span) {
481
485
  const records = await this.queryMaybeDeletedRecords(Q.where('_status', Q.notEq('synced')))
482
486
 
483
487
  if (records.length) {