musora-content-services 2.94.8 → 2.95.0
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/CLAUDE.md +408 -0
- package/babel.config.cjs +10 -0
- package/jsdoc.json +2 -1
- package/package.json +2 -2
- package/src/constants/award-assets.js +35 -0
- package/src/filterBuilder.js +7 -2
- package/src/index.d.ts +26 -5
- package/src/index.js +26 -5
- package/src/services/awards/award-callbacks.js +126 -0
- package/src/services/awards/award-query.js +327 -0
- package/src/services/awards/internal/.indexignore +1 -0
- package/src/services/awards/internal/award-definitions.js +239 -0
- package/src/services/awards/internal/award-events.js +102 -0
- package/src/services/awards/internal/award-manager.js +162 -0
- package/src/services/awards/internal/certificate-builder.js +66 -0
- package/src/services/awards/internal/completion-data-generator.js +84 -0
- package/src/services/awards/internal/content-progress-observer.js +137 -0
- package/src/services/awards/internal/image-utils.js +62 -0
- package/src/services/awards/internal/message-generator.js +17 -0
- package/src/services/awards/internal/types.js +5 -0
- package/src/services/awards/types.d.ts +79 -0
- package/src/services/awards/types.js +101 -0
- package/src/services/config.js +24 -4
- package/src/services/content-org/learning-paths.ts +19 -15
- package/src/services/gamification/awards.ts +114 -83
- package/src/services/progress-events.js +58 -0
- package/src/services/progress-row/method-card.js +20 -5
- package/src/services/sanity.js +1 -1
- package/src/services/sync/fetch.ts +10 -2
- package/src/services/sync/manager.ts +6 -0
- package/src/services/sync/models/ContentProgress.ts +5 -6
- package/src/services/sync/models/UserAwardProgress.ts +55 -0
- package/src/services/sync/models/index.ts +1 -0
- package/src/services/sync/repositories/content-progress.ts +47 -25
- package/src/services/sync/repositories/index.ts +1 -0
- package/src/services/sync/repositories/practices.ts +16 -1
- package/src/services/sync/repositories/user-award-progress.ts +133 -0
- package/src/services/sync/repository-proxy.ts +6 -0
- package/src/services/sync/retry.ts +12 -11
- package/src/services/sync/schema/index.ts +18 -3
- package/src/services/sync/store/index.ts +53 -8
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +7 -1
- package/src/services/userActivity.js +0 -1
- package/test/HttpClient.test.js +6 -6
- package/test/awards/award-alacarte-observer.test.js +196 -0
- package/test/awards/award-auto-refresh.test.js +83 -0
- package/test/awards/award-calculations.test.js +33 -0
- package/test/awards/award-certificate-display.test.js +328 -0
- package/test/awards/award-collection-edge-cases.test.js +210 -0
- package/test/awards/award-collection-filtering.test.js +285 -0
- package/test/awards/award-completion-flow.test.js +213 -0
- package/test/awards/award-exclusion-handling.test.js +273 -0
- package/test/awards/award-multi-lesson.test.js +241 -0
- package/test/awards/award-observer-integration.test.js +325 -0
- package/test/awards/award-query-messages.test.js +438 -0
- package/test/awards/award-user-collection.test.js +412 -0
- package/test/awards/duplicate-prevention.test.js +118 -0
- package/test/awards/helpers/completion-mock.js +54 -0
- package/test/awards/helpers/index.js +3 -0
- package/test/awards/helpers/mock-setup.js +69 -0
- package/test/awards/helpers/progress-emitter.js +39 -0
- package/test/awards/message-generator.test.js +162 -0
- package/test/initializeTests.js +6 -0
- package/test/mockData/award-definitions.js +171 -0
- package/test/sync/models/award-database-integration.test.js +519 -0
- package/tools/generate-index.cjs +9 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as ContentLikesRepository } from './content-likes'
|
|
2
2
|
export { default as ContentProgressRepository } from './content-progress'
|
|
3
3
|
export { default as PracticesRepository } from './practices'
|
|
4
|
+
export { default as UserAwardProgressRepository } from './user-award-progress'
|
|
4
5
|
export { default as PracticeDayNotesRepository } from './practice-day-notes'
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import SyncRepository from "./base";
|
|
1
|
+
import SyncRepository, { Q } from "./base";
|
|
2
2
|
import Practice from "../models/Practice";
|
|
3
3
|
import { RecordId } from "@nozbe/watermelondb";
|
|
4
4
|
|
|
5
5
|
export default class PracticesRepository extends SyncRepository<Practice> {
|
|
6
|
+
async sumPracticeMinutesForContent(contentIds: number[]): Promise<number> {
|
|
7
|
+
if (contentIds.length === 0) return 0
|
|
8
|
+
|
|
9
|
+
const practices = await this.queryAll(
|
|
10
|
+
Q.where('content_id', Q.oneOf(contentIds))
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const totalSeconds = practices.data.reduce(
|
|
14
|
+
(sum, practice) => sum + practice.duration_seconds,
|
|
15
|
+
0
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
return Math.round(totalSeconds / 60)
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
async trackAutoPractice(contentId: number, date: string, incrementalDurationSeconds: number) {
|
|
7
22
|
return await this.upsertOne(PracticesRepository.generateAutoId(contentId, date), r => {
|
|
8
23
|
r._raw.id = PracticesRepository.generateAutoId(contentId, date);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Q } from '@nozbe/watermelondb'
|
|
2
|
+
import UserAwardProgress from '../models/UserAwardProgress'
|
|
3
|
+
import SyncRepository from './base'
|
|
4
|
+
import type { AwardDefinition, CompletionData } from '../../awards/types'
|
|
5
|
+
import type { ModelSerialized } from '../serializers'
|
|
6
|
+
|
|
7
|
+
type AwardProgressData = {
|
|
8
|
+
completed_at: number | null
|
|
9
|
+
progress_percentage: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default class UserAwardProgressRepository extends SyncRepository<UserAwardProgress> {
|
|
13
|
+
static isCompleted(progress: AwardProgressData): boolean {
|
|
14
|
+
return progress.completed_at !== null && progress.progress_percentage === 100
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static isInProgress(progress: AwardProgressData): boolean {
|
|
18
|
+
return progress.progress_percentage > 0 && !UserAwardProgressRepository.isCompleted(progress)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static completedAtDate(progress: { completed_at: number | null }): Date | null {
|
|
22
|
+
return progress.completed_at ? new Date(progress.completed_at) : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async getAll(options?: {
|
|
26
|
+
limit?: number
|
|
27
|
+
onlyCompleted?: boolean
|
|
28
|
+
}) {
|
|
29
|
+
const clauses = []
|
|
30
|
+
|
|
31
|
+
if (options?.onlyCompleted) {
|
|
32
|
+
clauses.push(Q.where('completed_at', Q.notEq(null)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clauses.push(Q.sortBy('updated_at', Q.desc))
|
|
36
|
+
|
|
37
|
+
if (options?.limit) {
|
|
38
|
+
clauses.push(Q.take(options.limit))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return this.queryAll(...clauses as any)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getCompleted(limit?: number) {
|
|
45
|
+
return this.getAll({ onlyCompleted: true, limit })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getInProgress(limit?: number) {
|
|
49
|
+
const clauses: any[] = [
|
|
50
|
+
Q.where('progress_percentage', Q.gt(0)),
|
|
51
|
+
Q.where('completed_at', Q.eq(null)),
|
|
52
|
+
Q.sortBy('progress_percentage', Q.desc)
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
if (limit) {
|
|
56
|
+
clauses.push(Q.take(limit))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return this.queryAll(...clauses)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getByAwardId(awardId: string) {
|
|
63
|
+
return this.readOne(awardId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async hasCompletedAward(awardId: string): Promise<boolean> {
|
|
67
|
+
const result = await this.readOne(awardId)
|
|
68
|
+
if (!result.data) return false
|
|
69
|
+
return UserAwardProgressRepository.isCompleted(result.data)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async recordAwardProgress(
|
|
73
|
+
awardId: string,
|
|
74
|
+
progressPercentage: number,
|
|
75
|
+
options?: {
|
|
76
|
+
completedAt?: number | null
|
|
77
|
+
progressData?: any
|
|
78
|
+
completionData?: CompletionData | null
|
|
79
|
+
immediate?: boolean
|
|
80
|
+
}
|
|
81
|
+
) {
|
|
82
|
+
const builder = (record: UserAwardProgress) => {
|
|
83
|
+
record.award_id = awardId
|
|
84
|
+
record.progress_percentage = progressPercentage
|
|
85
|
+
|
|
86
|
+
if (options?.completedAt !== undefined) {
|
|
87
|
+
record.completed_at = options.completedAt
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options?.progressData !== undefined) {
|
|
91
|
+
record.progress_data = options.progressData
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (options?.completionData !== undefined) {
|
|
95
|
+
record.completion_data = options.completionData
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.upsertOne(awardId, builder)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async completeAward(
|
|
103
|
+
awardId: string,
|
|
104
|
+
completionData: CompletionData
|
|
105
|
+
) {
|
|
106
|
+
return this.recordAwardProgress(awardId, 100, {
|
|
107
|
+
completedAt: Date.now(),
|
|
108
|
+
completionData,
|
|
109
|
+
immediate: true
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async getAwardsForContent(contentId: number): Promise<{
|
|
114
|
+
definitions: AwardDefinition[]
|
|
115
|
+
progress: Map<string, ModelSerialized<UserAwardProgress>>
|
|
116
|
+
}> {
|
|
117
|
+
const { awardDefinitions } = await import('../../awards/internal/award-definitions')
|
|
118
|
+
|
|
119
|
+
const definitions = await awardDefinitions.getByContentId(contentId)
|
|
120
|
+
|
|
121
|
+
const awardIds = definitions.map(d => d._id)
|
|
122
|
+
const progressMap = new Map<string, ModelSerialized<UserAwardProgress>>()
|
|
123
|
+
|
|
124
|
+
for (const awardId of awardIds) {
|
|
125
|
+
const result = await this.getByAwardId(awardId)
|
|
126
|
+
if (result.data) {
|
|
127
|
+
progressMap.set(awardId, result.data)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { definitions, progress: progressMap }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
PracticesRepository,
|
|
8
8
|
PracticeDayNotesRepository
|
|
9
9
|
} from "./repositories"
|
|
10
|
+
import UserAwardProgressRepository from "./repositories/user-award-progress"
|
|
10
11
|
import {
|
|
11
12
|
ContentLike,
|
|
12
13
|
ContentProgress,
|
|
13
14
|
Practice,
|
|
15
|
+
UserAwardProgress,
|
|
14
16
|
PracticeDayNote
|
|
15
17
|
} from "./models"
|
|
16
18
|
|
|
@@ -19,6 +21,7 @@ interface SyncRepositories {
|
|
|
19
21
|
likes: ContentLikesRepository;
|
|
20
22
|
contentProgress: ContentProgressRepository;
|
|
21
23
|
practices: PracticesRepository;
|
|
24
|
+
userAwardProgress: UserAwardProgressRepository;
|
|
22
25
|
practiceDayNotes: PracticeDayNotesRepository;
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -47,6 +50,9 @@ const proxy = new Proxy({} as SyncRepositories, {
|
|
|
47
50
|
case 'practices':
|
|
48
51
|
cache.practices = new PracticesRepository(manager.getStore(Practice));
|
|
49
52
|
break;
|
|
53
|
+
case 'userAwardProgress':
|
|
54
|
+
cache.userAwardProgress = new UserAwardProgressRepository(manager.getStore(UserAwardProgress));
|
|
55
|
+
break;
|
|
50
56
|
case 'practiceDayNotes':
|
|
51
57
|
cache.practiceDayNotes = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote));
|
|
52
58
|
break;
|
|
@@ -32,16 +32,10 @@ export default class SyncRetry {
|
|
|
32
32
|
* Runs the given syncFn with automatic retries.
|
|
33
33
|
* Returns the first successful result or the last failed result after retries.
|
|
34
34
|
*/
|
|
35
|
-
async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T>) {
|
|
35
|
+
async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T | void>) {
|
|
36
36
|
let attempt = 0
|
|
37
37
|
|
|
38
38
|
while (true) {
|
|
39
|
-
if (!this.context.connectivity.getValue()) {
|
|
40
|
-
this.telemetry.debug('[Retry] No connectivity - skipping')
|
|
41
|
-
this.paused = true
|
|
42
|
-
return { ok: false } as T
|
|
43
|
-
}
|
|
44
|
-
|
|
45
39
|
const now = Date.now()
|
|
46
40
|
if (now < this.backoffUntil) {
|
|
47
41
|
await this.sleep(this.backoffUntil - now)
|
|
@@ -50,15 +44,22 @@ export default class SyncRetry {
|
|
|
50
44
|
attempt++
|
|
51
45
|
|
|
52
46
|
const spanOptions = { ...spanOpts, name: `${spanOpts.name}:attempt:${attempt}/${this.MAX_ATTEMPTS}`, op: `${spanOpts.op}:attempt` }
|
|
53
|
-
const result = await this.telemetry.trace(spanOptions, span =>
|
|
47
|
+
const result = await this.telemetry.trace(spanOptions, span => {
|
|
48
|
+
if (!this.context.connectivity.getValue()) {
|
|
49
|
+
this.telemetry.debug('[Retry] No connectivity - skipping')
|
|
50
|
+
return { ok: false } as T
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return syncFn(span)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (!result) return result
|
|
54
57
|
|
|
55
58
|
if (result.ok) {
|
|
56
59
|
this.resetBackoff()
|
|
57
60
|
return result
|
|
58
61
|
} else {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (isRetryable) {
|
|
62
|
+
if (result.failureType === 'fetch' && result.isRetryable) {
|
|
62
63
|
this.scheduleBackoff()
|
|
63
64
|
if (attempt >= this.MAX_ATTEMPTS) return result
|
|
64
65
|
} else {
|
|
@@ -4,7 +4,8 @@ export const SYNC_TABLES = {
|
|
|
4
4
|
CONTENT_LIKES: 'content_likes',
|
|
5
5
|
CONTENT_PROGRESS: 'progress',
|
|
6
6
|
PRACTICES: 'practices',
|
|
7
|
-
PRACTICE_DAY_NOTES: 'practice_day_notes'
|
|
7
|
+
PRACTICE_DAY_NOTES: 'practice_day_notes',
|
|
8
|
+
USER_AWARD_PROGRESS: 'user_award_progress'
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const contentLikesTable = tableSchema({
|
|
@@ -24,7 +25,7 @@ const contentProgressTable = tableSchema({
|
|
|
24
25
|
{ name: 'collection_id', type: 'number', isOptional: true, isIndexed: true },
|
|
25
26
|
{ name: 'state', type: 'string', isIndexed: true },
|
|
26
27
|
{ name: 'progress_percent', type: 'number' },
|
|
27
|
-
{ name: 'resume_time_seconds', type: 'number' },
|
|
28
|
+
{ name: 'resume_time_seconds', type: 'number', isOptional: true },
|
|
28
29
|
{ name: 'created_at', type: 'number' },
|
|
29
30
|
{ name: 'updated_at', type: 'number', isIndexed: true }
|
|
30
31
|
]
|
|
@@ -55,12 +56,26 @@ const practiceDayNotesTable = tableSchema({
|
|
|
55
56
|
]
|
|
56
57
|
})
|
|
57
58
|
|
|
59
|
+
const userAwardProgressTable = tableSchema({
|
|
60
|
+
name: SYNC_TABLES.USER_AWARD_PROGRESS,
|
|
61
|
+
columns: [
|
|
62
|
+
{ name: 'award_id', type: 'string', isIndexed: true },
|
|
63
|
+
{ name: 'progress_percentage', type: 'number' },
|
|
64
|
+
{ name: 'completed_at', type: 'number', isOptional: true, isIndexed: true },
|
|
65
|
+
{ name: 'progress_data', type: 'string', isOptional: true },
|
|
66
|
+
{ name: 'completion_data', type: 'string', isOptional: true },
|
|
67
|
+
{ name: 'created_at', type: 'number' },
|
|
68
|
+
{ name: 'updated_at', type: 'number', isIndexed: true }
|
|
69
|
+
]
|
|
70
|
+
})
|
|
71
|
+
|
|
58
72
|
export default appSchema({
|
|
59
73
|
version: 1,
|
|
60
74
|
tables: [
|
|
61
75
|
contentLikesTable,
|
|
62
76
|
contentProgressTable,
|
|
63
77
|
practicesTable,
|
|
64
|
-
practiceDayNotesTable
|
|
78
|
+
practiceDayNotesTable,
|
|
79
|
+
userAwardProgressTable
|
|
65
80
|
]
|
|
66
81
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database, Q, type Collection, type RecordId } from '@nozbe/watermelondb'
|
|
2
2
|
import { RawSerializer, ModelSerializer } from '../serializers'
|
|
3
3
|
import { ModelClass, SyncToken, SyncEntry, SyncContext, EpochMs } from '..'
|
|
4
|
-
import { SyncPullResponse, SyncPushResponse, PushPayload } from '../fetch'
|
|
4
|
+
import { SyncPullResponse, SyncPushResponse, SyncPushFailureResponse, PushPayload } from '../fetch'
|
|
5
5
|
import type SyncRetry from '../retry'
|
|
6
6
|
import type SyncRunScope from '../run-scope'
|
|
7
7
|
import EventEmitter from '../utils/event-emitter'
|
|
@@ -17,6 +17,7 @@ import { type WriterInterface } from '@nozbe/watermelondb/Database/WorkQueue'
|
|
|
17
17
|
import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
18
18
|
import { SyncError } from '../errors'
|
|
19
19
|
|
|
20
|
+
|
|
20
21
|
type SyncPull = (
|
|
21
22
|
session: BaseSessionProvider,
|
|
22
23
|
previousFetchToken: SyncToken | null,
|
|
@@ -244,15 +245,39 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
244
245
|
const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))))
|
|
245
246
|
const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
246
247
|
|
|
247
|
-
const destroyedBuilds =
|
|
248
|
-
|
|
248
|
+
const destroyedBuilds = []
|
|
249
|
+
const recreateBuilds: Array<{ id: RecordId; created_at: EpochMs; builder: (record: TModel) => void }> = []
|
|
250
|
+
|
|
251
|
+
existing.forEach(record => {
|
|
252
|
+
if (record._raw._status === 'deleted') {
|
|
253
|
+
destroyedBuilds.push(new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
|
|
254
|
+
} else if (record._raw._status === 'created' && builders[record.id]) {
|
|
255
|
+
// Workaround for WatermelonDB bug: prepareUpdate() doesn't commit field changes
|
|
256
|
+
// for records with _status='created'. Destroy and recreate to ensure updates persist.
|
|
257
|
+
destroyedBuilds.push(new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
|
|
258
|
+
recreateBuilds.push({
|
|
259
|
+
id: record.id,
|
|
260
|
+
created_at: record._raw.created_at,
|
|
261
|
+
builder: builders[record.id]
|
|
262
|
+
})
|
|
263
|
+
}
|
|
249
264
|
})
|
|
265
|
+
|
|
250
266
|
const newBuilds = Object.entries(builders).map(([id, builder]) => {
|
|
251
267
|
const existing = existingMap.get(id)
|
|
268
|
+
const recreate = recreateBuilds.find(r => r.id === id)
|
|
252
269
|
|
|
253
|
-
if (
|
|
270
|
+
if (recreate) {
|
|
271
|
+
return this.collection.prepareCreate(record => {
|
|
272
|
+
record._raw.id = id
|
|
273
|
+
record._raw.created_at = recreate.created_at
|
|
274
|
+
record._raw.updated_at = this.generateTimestamp()
|
|
275
|
+
record._raw._status = 'created'
|
|
276
|
+
builder(record)
|
|
277
|
+
})
|
|
278
|
+
} else if (existing && existing._raw._status !== 'deleted' && existing._raw._status !== 'created') {
|
|
254
279
|
return existing.prepareUpdate(builder)
|
|
255
|
-
} else {
|
|
280
|
+
} else if (!existing || existing._raw._status === 'deleted') {
|
|
256
281
|
return this.collection.prepareCreate(record => {
|
|
257
282
|
const now = this.generateTimestamp()
|
|
258
283
|
|
|
@@ -262,7 +287,8 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
262
287
|
builder(record)
|
|
263
288
|
})
|
|
264
289
|
}
|
|
265
|
-
|
|
290
|
+
return null
|
|
291
|
+
}).filter((build): build is ReturnType<typeof this.collection.prepareCreate> => build !== null)
|
|
266
292
|
|
|
267
293
|
await writer.batch(...destroyedBuilds)
|
|
268
294
|
await writer.batch(...newBuilds)
|
|
@@ -455,12 +481,26 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
455
481
|
const records = await this.queryMaybeDeletedRecords(Q.where('_status', Q.notEq('synced')))
|
|
456
482
|
|
|
457
483
|
if (records.length) {
|
|
484
|
+
const recordIds = records.map(r => r.id)
|
|
485
|
+
const updatedAtMap = new Map<RecordId, EpochMs>()
|
|
486
|
+
records.forEach(record => {
|
|
487
|
+
updatedAtMap.set(record.id, record._raw.updated_at)
|
|
488
|
+
})
|
|
489
|
+
|
|
458
490
|
this.pushCoalescer.push(
|
|
459
491
|
records,
|
|
460
492
|
queueThrottle({ state: this.pushThrottleState }, () => {
|
|
461
|
-
return this.retry.request(
|
|
493
|
+
return this.retry.request<SyncPushResponse>(
|
|
462
494
|
{ name: `push:${this.model.table}`, op: 'push', parentSpan: span },
|
|
463
|
-
(span) =>
|
|
495
|
+
async (span) => {
|
|
496
|
+
// re-query records since this fn may be deferred due to throttling/retries
|
|
497
|
+
const currentRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(recordIds)))
|
|
498
|
+
const recordsToPush = currentRecords.filter(r => r._raw.updated_at <= (updatedAtMap.get(r.id) || 0))
|
|
499
|
+
|
|
500
|
+
if (recordsToPush.length) {
|
|
501
|
+
return this.executePush(recordsToPush, span)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
464
504
|
)
|
|
465
505
|
})
|
|
466
506
|
)
|
|
@@ -468,6 +508,11 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
468
508
|
}
|
|
469
509
|
|
|
470
510
|
private async executePull(span?: Span) {
|
|
511
|
+
if (!this.context.connectivity.getValue()) {
|
|
512
|
+
this.telemetry.debug('[Retry] No connectivity - skipping')
|
|
513
|
+
return { ok: false } as SyncPushFailureResponse
|
|
514
|
+
}
|
|
515
|
+
|
|
471
516
|
return this.telemetry.trace(
|
|
472
517
|
{
|
|
473
518
|
name: `pull:${this.model.table}:run`,
|
|
@@ -4,7 +4,7 @@ import { EpochMs } from ".."
|
|
|
4
4
|
import { SyncPushResponse } from "../fetch"
|
|
5
5
|
|
|
6
6
|
type PushIntent = {
|
|
7
|
-
promise: Promise<SyncPushResponse>
|
|
7
|
+
promise: Promise<void | SyncPushResponse>
|
|
8
8
|
records: {
|
|
9
9
|
id: RecordId
|
|
10
10
|
updatedAt: EpochMs
|
|
@@ -18,7 +18,7 @@ export default class PushCoalescer {
|
|
|
18
18
|
this.intents = []
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<SyncPushResponse>) {
|
|
21
|
+
push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<void | SyncPushResponse>) {
|
|
22
22
|
const found = this.find(records)
|
|
23
23
|
|
|
24
24
|
if (found) {
|
|
@@ -28,7 +28,7 @@ export default class PushCoalescer {
|
|
|
28
28
|
return this.add(pusher(records), records)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
|
|
31
|
+
private add(promise: Promise<void | SyncPushResponse>, records: BaseModel[]) {
|
|
32
32
|
const intent = {
|
|
33
33
|
promise,
|
|
34
34
|
records: records.map(record => ({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SyncStoreConfig } from "./store"
|
|
2
|
-
import { ContentLike, ContentProgress, Practice, PracticeDayNote } from "./models"
|
|
2
|
+
import { ContentLike, ContentProgress, Practice, UserAwardProgress, PracticeDayNote } from "./models"
|
|
3
3
|
import { handlePull, handlePush, makeFetchRequest } from "./fetch"
|
|
4
4
|
|
|
5
5
|
import type SyncStore from "./store"
|
|
@@ -36,6 +36,12 @@ export default function createStoresFromConfig(createStore: <TModel extends Base
|
|
|
36
36
|
model: PracticeDayNote,
|
|
37
37
|
pull: handlePull(makeFetchRequest('/api/user/practices/v1/notes')),
|
|
38
38
|
push: handlePush(makeFetchRequest('/api/user/practices/v1/notes', { method: 'POST' })),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
createStore({
|
|
42
|
+
model: UserAwardProgress,
|
|
43
|
+
pull: handlePull(makeFetchRequest('/api/content/v1/user/awards')),
|
|
44
|
+
push: handlePush(makeFetchRequest('/api/content/v1/user/awards', { method: 'POST' })),
|
|
39
45
|
})
|
|
40
46
|
] as unknown as SyncStore<BaseModel>[]
|
|
41
47
|
}
|
package/test/HttpClient.test.js
CHANGED
|
@@ -65,7 +65,7 @@ describe('HttpClient', () => {
|
|
|
65
65
|
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
66
66
|
`${baseUrl}${url}`,
|
|
67
67
|
expect.objectContaining({
|
|
68
|
-
method: '
|
|
68
|
+
method: 'GET',
|
|
69
69
|
headers: expect.objectContaining({
|
|
70
70
|
Authorization: `Bearer ${token}`,
|
|
71
71
|
}),
|
|
@@ -82,7 +82,7 @@ describe('HttpClient', () => {
|
|
|
82
82
|
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
83
83
|
`${baseUrl}${url}`,
|
|
84
84
|
expect.objectContaining({
|
|
85
|
-
method: '
|
|
85
|
+
method: 'POST',
|
|
86
86
|
headers: expect.objectContaining({
|
|
87
87
|
Authorization: `Bearer ${token}`,
|
|
88
88
|
}),
|
|
@@ -100,7 +100,7 @@ describe('HttpClient', () => {
|
|
|
100
100
|
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
101
101
|
`${baseUrl}${url}`,
|
|
102
102
|
expect.objectContaining({
|
|
103
|
-
method: '
|
|
103
|
+
method: 'PUT',
|
|
104
104
|
headers: expect.objectContaining({
|
|
105
105
|
Authorization: `Bearer ${token}`,
|
|
106
106
|
}),
|
|
@@ -118,7 +118,7 @@ describe('HttpClient', () => {
|
|
|
118
118
|
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
119
119
|
`${baseUrl}${url}`,
|
|
120
120
|
expect.objectContaining({
|
|
121
|
-
method: '
|
|
121
|
+
method: 'PATCH',
|
|
122
122
|
headers: expect.objectContaining({
|
|
123
123
|
Authorization: `Bearer ${token}`,
|
|
124
124
|
}),
|
|
@@ -135,7 +135,7 @@ describe('HttpClient', () => {
|
|
|
135
135
|
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
136
136
|
`${baseUrl}${url}`,
|
|
137
137
|
expect.objectContaining({
|
|
138
|
-
method: '
|
|
138
|
+
method: 'DELETE',
|
|
139
139
|
headers: expect.objectContaining({
|
|
140
140
|
Authorization: `Bearer ${token}`,
|
|
141
141
|
}),
|
|
@@ -249,7 +249,7 @@ describe('HttpClient', () => {
|
|
|
249
249
|
await expect(httpClient.get('/test')).rejects.toMatchObject({
|
|
250
250
|
message: 'Network error',
|
|
251
251
|
url: '/test',
|
|
252
|
-
method: '
|
|
252
|
+
method: 'GET',
|
|
253
253
|
originalError: networkError,
|
|
254
254
|
})
|
|
255
255
|
})
|