musora-content-services 2.89.0 → 2.92.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/docs/ContentOrganization.html +2 -2
- package/docs/Forums.html +2 -2
- package/docs/Gamification.html +2 -2
- package/docs/TestUser.html +2 -2
- package/docs/UserManagementSystem.html +2 -2
- package/docs/api_types.js.html +2 -2
- package/docs/config.js.html +2 -2
- package/docs/content-org_content-org.js.html +2 -2
- package/docs/content-org_guided-courses.ts.html +2 -2
- package/docs/content-org_learning-paths.ts.html +52 -40
- package/docs/content-org_playlists-types.js.html +2 -2
- package/docs/content-org_playlists.js.html +2 -2
- package/docs/content.js.html +2 -2
- package/docs/content_artist.ts.html +2 -2
- package/docs/content_genre.ts.html +2 -2
- package/docs/content_instructor.ts.html +2 -2
- package/docs/forums_categories.ts.html +2 -2
- package/docs/forums_forums.ts.html +2 -2
- package/docs/forums_posts.ts.html +2 -2
- package/docs/forums_threads.ts.html +2 -2
- package/docs/gamification_awards.ts.html +2 -2
- package/docs/gamification_gamification.js.html +2 -2
- package/docs/global.html +2 -2
- package/docs/index.html +2 -2
- package/docs/liveTesting.ts.html +2 -2
- package/docs/module-Accounts.html +2 -2
- package/docs/module-Artist.html +2 -2
- package/docs/module-Awards.html +2 -2
- package/docs/module-Config.html +2 -2
- package/docs/module-Content-Services-V2.html +2 -2
- package/docs/module-Forums.html +2 -2
- package/docs/module-Genre.html +2 -2
- package/docs/module-GuidedCourses.html +2 -2
- package/docs/module-Instructor.html +2 -2
- package/docs/module-Interests.html +2 -2
- package/docs/module-LearningPaths.html +269 -143
- package/docs/module-Onboarding.html +3 -3
- package/docs/module-Payments.html +2 -2
- package/docs/module-Permissions.html +2 -2
- package/docs/module-Playlists.html +2 -2
- package/docs/module-ProgressRow.html +2 -2
- package/docs/module-Railcontent-Services.html +34 -893
- package/docs/module-Sanity-Services.html +2 -2
- package/docs/module-Sessions.html +2 -2
- package/docs/module-UserActivity.html +70 -116
- package/docs/module-UserChat.html +2 -2
- package/docs/module-UserManagement.html +2 -2
- package/docs/module-UserMemberships.html +2 -2
- package/docs/module-UserNotifications.html +2 -2
- package/docs/module-UserProfile.html +2 -2
- package/docs/progress-row_method-card.js.html +3 -2
- package/docs/railcontent.js.html +14 -137
- package/docs/sanity.js.html +2 -2
- package/docs/userActivity.js.html +85 -150
- package/docs/user_account.ts.html +2 -2
- package/docs/user_chat.js.html +2 -2
- package/docs/user_interests.js.html +2 -2
- package/docs/user_management.js.html +2 -2
- package/docs/user_memberships.ts.html +2 -2
- package/docs/user_notifications.js.html +2 -2
- package/docs/user_onboarding.ts.html +10 -6
- package/docs/user_payments.ts.html +2 -2
- package/docs/user_permissions.js.html +2 -2
- package/docs/user_profile.js.html +2 -2
- package/docs/user_sessions.js.html +2 -2
- package/docs/user_types.js.html +2 -2
- package/docs/user_user-management-system.js.html +2 -2
- package/package.json +11 -3
- package/src/contentTypeConfig.js +6 -0
- package/src/index.d.ts +7 -31
- package/src/index.js +10 -34
- package/src/services/content-org/learning-paths.ts +31 -0
- package/src/services/contentAggregator.js +2 -2
- package/src/services/contentLikes.js +6 -39
- package/src/services/contentProgress.js +181 -479
- package/src/services/dataContext.js +0 -2
- package/src/services/progress-row/method-card.js +1 -0
- package/src/services/railcontent.js +12 -135
- package/src/services/sentry/.indexignore +0 -0
- package/src/services/sentry/index.ts +23 -0
- package/src/services/sync/.indexignore +0 -0
- package/src/services/sync/adapters/factory.ts +26 -0
- package/src/services/sync/adapters/lokijs.ts +1 -0
- package/src/services/sync/adapters/sqlite.ts +1 -0
- package/src/services/sync/concurrency-safety.ts +4 -0
- package/src/services/sync/context/index.ts +43 -0
- package/src/services/sync/context/providers/base.ts +4 -0
- package/src/services/sync/context/providers/connectivity.ts +14 -0
- package/src/services/sync/context/providers/durability.ts +5 -0
- package/src/services/sync/context/providers/index.ts +5 -0
- package/src/services/sync/context/providers/session.ts +8 -0
- package/src/services/sync/context/providers/tabs.ts +18 -0
- package/src/services/sync/context/providers/visibility.ts +14 -0
- package/src/services/sync/database/factory.ts +10 -0
- package/src/services/sync/errors/boundary.ts +45 -0
- package/src/services/sync/errors/index.ts +49 -0
- package/src/services/sync/fetch.ts +310 -0
- package/src/services/sync/index.ts +80 -0
- package/src/services/sync/manager.ts +139 -0
- package/src/services/sync/models/Base.ts +47 -0
- package/src/services/sync/models/ContentLike.ts +16 -0
- package/src/services/sync/models/ContentProgress.ts +69 -0
- package/src/services/sync/models/Practice.ts +72 -0
- package/src/services/sync/models/PracticeDayNote.ts +23 -0
- package/src/services/sync/models/index.ts +4 -0
- package/src/services/sync/repositories/base.ts +247 -0
- package/src/services/sync/repositories/content-likes.ts +26 -0
- package/src/services/sync/repositories/content-progress.ts +160 -0
- package/src/services/sync/repositories/index.ts +4 -0
- package/src/services/sync/repositories/practice-day-notes.ts +4 -0
- package/src/services/sync/repositories/practices.ts +52 -0
- package/src/services/sync/repository-proxy.ts +48 -0
- package/src/services/sync/resolver.ts +84 -0
- package/src/services/sync/retry.ts +88 -0
- package/src/services/sync/run-scope.ts +30 -0
- package/src/services/sync/schema/index.ts +66 -0
- package/src/services/sync/serializers/index.ts +2 -0
- package/src/services/sync/serializers/model.ts +32 -0
- package/src/services/sync/serializers/raw.ts +21 -0
- package/src/services/sync/store/index.ts +779 -0
- package/src/services/sync/store/push-coalescer.ts +57 -0
- package/src/services/sync/store-configs.ts +41 -0
- package/src/services/sync/strategies/base.ts +21 -0
- package/src/services/sync/strategies/index.ts +12 -0
- package/src/services/sync/strategies/initial.ts +11 -0
- package/src/services/sync/strategies/polling.ts +54 -0
- package/src/services/sync/telemetry/index.ts +140 -0
- package/src/services/sync/telemetry/sampling.ts +91 -0
- package/src/services/sync/utils/event-emitter.ts +24 -0
- package/src/services/sync/utils/index.ts +1 -0
- package/src/services/sync/utils/throttle.ts +93 -0
- package/src/services/sync/utils/timers.ts +9 -0
- package/src/services/userActivity.js +83 -148
- package/test/contentProgress.test.js +6 -39
- package/test/live/contentProgressLive.test.js +2 -31
- package/tools/generate-index.cjs +10 -4
- package/.claude/settings.local.json +0 -8
- package/babel.config.cjs +0 -3
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { Database, Q, type Collection, type RecordId } from '@nozbe/watermelondb'
|
|
2
|
+
import { RawSerializer, ModelSerializer } from '../serializers'
|
|
3
|
+
import { ModelClass, SyncToken, SyncEntry, SyncContext, EpochMs } from '..'
|
|
4
|
+
import { SyncPullResponse, SyncPushResponse, PushPayload } from '../fetch'
|
|
5
|
+
import type SyncRetry from '../retry'
|
|
6
|
+
import type SyncRunScope from '../run-scope'
|
|
7
|
+
import EventEmitter from '../utils/event-emitter'
|
|
8
|
+
import BaseModel from '../models/Base'
|
|
9
|
+
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'
|
|
10
|
+
import { default as Resolver, type SyncResolution, type SyncResolverComparator } from '../resolver'
|
|
11
|
+
import PushCoalescer from './push-coalescer'
|
|
12
|
+
import { SyncTelemetry, Span } from '../telemetry/index'
|
|
13
|
+
import { inBoundary } from '../errors/boundary'
|
|
14
|
+
import { BaseSessionProvider } from '../context/providers'
|
|
15
|
+
import { dropThrottle, queueThrottle, createThrottleState, type ThrottleState } from '../utils'
|
|
16
|
+
import { type WriterInterface } from '@nozbe/watermelondb/Database/WorkQueue'
|
|
17
|
+
import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
|
|
18
|
+
import { SyncError } from '../errors'
|
|
19
|
+
|
|
20
|
+
type SyncPull = (
|
|
21
|
+
session: BaseSessionProvider,
|
|
22
|
+
previousFetchToken: SyncToken | null,
|
|
23
|
+
signal: AbortSignal
|
|
24
|
+
) => Promise<SyncPullResponse>
|
|
25
|
+
type SyncPush = (
|
|
26
|
+
session: BaseSessionProvider,
|
|
27
|
+
payload: PushPayload,
|
|
28
|
+
signal: AbortSignal
|
|
29
|
+
) => Promise<SyncPushResponse>
|
|
30
|
+
|
|
31
|
+
export type SyncStoreConfig<TModel extends BaseModel> = {
|
|
32
|
+
model: ModelClass<TModel>
|
|
33
|
+
comparator?: TModel extends BaseModel ? SyncResolverComparator<TModel> : SyncResolverComparator
|
|
34
|
+
pull: SyncPull
|
|
35
|
+
push: SyncPush
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
39
|
+
static readonly PULL_THROTTLE_INTERVAL = 2_000
|
|
40
|
+
static readonly PUSH_THROTTLE_INTERVAL = 1_000
|
|
41
|
+
|
|
42
|
+
readonly telemetry: SyncTelemetry
|
|
43
|
+
readonly context: SyncContext
|
|
44
|
+
readonly retry: SyncRetry
|
|
45
|
+
readonly runScope: SyncRunScope
|
|
46
|
+
readonly db: Database
|
|
47
|
+
readonly model: ModelClass<TModel>
|
|
48
|
+
readonly collection: Collection<TModel>
|
|
49
|
+
|
|
50
|
+
readonly resolverComparator?: SyncResolverComparator
|
|
51
|
+
readonly rawSerializer: RawSerializer<TModel>
|
|
52
|
+
readonly modelSerializer: ModelSerializer<TModel>
|
|
53
|
+
|
|
54
|
+
readonly puller: SyncPull
|
|
55
|
+
readonly pusher: SyncPush
|
|
56
|
+
|
|
57
|
+
private pullThrottleState: ThrottleState<SyncPullResponse>
|
|
58
|
+
private pushThrottleState: ThrottleState<SyncPushResponse>
|
|
59
|
+
private pushCoalescer = new PushCoalescer()
|
|
60
|
+
|
|
61
|
+
private emitter = new EventEmitter()
|
|
62
|
+
|
|
63
|
+
private lastFetchTokenKey: string
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
{ model, comparator, pull, push }: SyncStoreConfig<TModel>,
|
|
67
|
+
context: SyncContext,
|
|
68
|
+
db: Database,
|
|
69
|
+
retry: SyncRetry,
|
|
70
|
+
runScope: SyncRunScope,
|
|
71
|
+
telemetry: SyncTelemetry
|
|
72
|
+
) {
|
|
73
|
+
this.context = context
|
|
74
|
+
this.retry = retry
|
|
75
|
+
this.runScope = runScope
|
|
76
|
+
this.db = db
|
|
77
|
+
this.model = model
|
|
78
|
+
this.collection = db.collections.get(model.table)
|
|
79
|
+
this.rawSerializer = new RawSerializer()
|
|
80
|
+
this.modelSerializer = new ModelSerializer()
|
|
81
|
+
|
|
82
|
+
this.resolverComparator = comparator
|
|
83
|
+
|
|
84
|
+
this.pushCoalescer = new PushCoalescer()
|
|
85
|
+
this.pushThrottleState = createThrottleState(SyncStore.PUSH_THROTTLE_INTERVAL)
|
|
86
|
+
this.pullThrottleState = createThrottleState(SyncStore.PULL_THROTTLE_INTERVAL)
|
|
87
|
+
|
|
88
|
+
this.puller = pull
|
|
89
|
+
this.pusher = push
|
|
90
|
+
this.lastFetchTokenKey = `last_fetch_token:${this.model.table}`
|
|
91
|
+
|
|
92
|
+
this.telemetry = telemetry
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
on = this.emitter.on.bind(this.emitter)
|
|
96
|
+
off = this.emitter.off.bind(this.emitter)
|
|
97
|
+
private emit = this.emitter.emit.bind(this.emitter)
|
|
98
|
+
|
|
99
|
+
async requestSync(reason: string) {
|
|
100
|
+
inBoundary(ctx => {
|
|
101
|
+
this.telemetry.trace(
|
|
102
|
+
{ name: `sync:${this.model.table}`, op: 'sync', attributes: ctx },
|
|
103
|
+
async span => {
|
|
104
|
+
let pushError: any = null
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await this.pushUnsyncedWithRetry(span)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
pushError = err
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// will return records that we just saw in push response, but we can't
|
|
113
|
+
// be sure there were no other changes before the push
|
|
114
|
+
await this.pullRecordsWithRetry(span)
|
|
115
|
+
|
|
116
|
+
if (pushError) {
|
|
117
|
+
throw pushError
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
}, { table: this.model.table, reason })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getLastFetchToken() {
|
|
125
|
+
return (await this.db.localStorage.get<SyncToken | null>(this.lastFetchTokenKey)) ?? null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async pullRecords(span?: Span) {
|
|
129
|
+
return dropThrottle({ state: this.pullThrottleState }, this.executePull.bind(this))(span)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async pushRecordIdsImpatiently(ids: RecordId[], span?: Span) {
|
|
133
|
+
const records = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))
|
|
134
|
+
|
|
135
|
+
return await this.pushCoalescer.push(
|
|
136
|
+
records,
|
|
137
|
+
queueThrottle({ state: this.pushThrottleState }, () => {
|
|
138
|
+
return this.executePush(records, span)
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async readAll() {
|
|
144
|
+
const records = await this.queryRecords()
|
|
145
|
+
return records.map((record) => this.modelSerializer.toPlainObject(record))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async readSome(ids: RecordId[]) {
|
|
149
|
+
const records = await this.queryRecords(Q.where('id', Q.oneOf(ids)))
|
|
150
|
+
return records.map((record) => this.modelSerializer.toPlainObject(record))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async readOne(id: RecordId) {
|
|
154
|
+
const record = await this.findRecord(id)
|
|
155
|
+
return record ? this.modelSerializer.toPlainObject(record) : null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async queryAll(...args: Q.Clause[]) {
|
|
159
|
+
const records = await this.queryRecords(...args)
|
|
160
|
+
return records.map((record) => this.modelSerializer.toPlainObject(record))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async queryAllIds(...args: Q.Clause[]) {
|
|
164
|
+
return this.queryRecordIds(...args)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async queryOne(...args: Q.Clause[]) {
|
|
168
|
+
const record = await this.queryRecord(...args)
|
|
169
|
+
return record ? this.modelSerializer.toPlainObject(record) : null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async queryOneId(...args: Q.Clause[]) {
|
|
173
|
+
return this.queryRecordId(...args)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async insertOne(builder: (record: TModel) => void, span?: Span) {
|
|
177
|
+
return await this.runScope.abortable(async () => {
|
|
178
|
+
const record = await this.telemeterizedWrite(span, async () => {
|
|
179
|
+
return this.collection.create(rec => {
|
|
180
|
+
builder(rec)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
this.emit('upserted', [record])
|
|
184
|
+
|
|
185
|
+
this.pushUnsyncedWithRetry(span)
|
|
186
|
+
await this.ensurePersistence()
|
|
187
|
+
|
|
188
|
+
return this.modelSerializer.toPlainObject(record)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async updateOneId(id: RecordId, builder: (record: TModel) => void, span?: Span) {
|
|
193
|
+
return await this.runScope.abortable(async () => {
|
|
194
|
+
const found = await this.findRecord(id)
|
|
195
|
+
|
|
196
|
+
if (!found) {
|
|
197
|
+
throw new SyncError('Record not found', { id })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const record = await this.telemeterizedWrite(span, async () => {
|
|
201
|
+
return found.update(builder)
|
|
202
|
+
})
|
|
203
|
+
this.emit('upserted', [record])
|
|
204
|
+
|
|
205
|
+
this.pushUnsyncedWithRetry(span)
|
|
206
|
+
await this.ensurePersistence()
|
|
207
|
+
|
|
208
|
+
return this.modelSerializer.toPlainObject(record)
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async upsertOneRemote(id: RecordId, builder: (record: TModel) => void, span?: Span) {
|
|
213
|
+
return await this.runScope.abortable(async () => {
|
|
214
|
+
let record: TModel
|
|
215
|
+
const existing = await this.queryMaybeDeletedRecords(Q.where('id', id)).then(r => r[0] || null)
|
|
216
|
+
|
|
217
|
+
if (existing) {
|
|
218
|
+
existing._isEditing = true
|
|
219
|
+
builder(existing)
|
|
220
|
+
existing._isEditing = false
|
|
221
|
+
record = existing
|
|
222
|
+
} else {
|
|
223
|
+
const attrs = new this.model(this.collection, { id })
|
|
224
|
+
attrs._isEditing = true
|
|
225
|
+
builder(attrs)
|
|
226
|
+
attrs._isEditing = false
|
|
227
|
+
record = this.collection.disposableFromDirtyRaw(attrs._raw)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return await this.pushCoalescer.push(
|
|
231
|
+
[record],
|
|
232
|
+
() => this.executePush([record], span)
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async upsertSome(builders: Record<RecordId, (record: TModel) => void>, span?: Span) {
|
|
238
|
+
if (Object.keys(builders).length === 0) return []
|
|
239
|
+
|
|
240
|
+
return await this.runScope.abortable(async () => {
|
|
241
|
+
const ids = Object.keys(builders)
|
|
242
|
+
|
|
243
|
+
const records = await this.telemeterizedWrite(span, async writer => {
|
|
244
|
+
const existing = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))
|
|
245
|
+
const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
246
|
+
|
|
247
|
+
const destroyedBuilds = existing.filter(record => record._raw._status === 'deleted').map(record => {
|
|
248
|
+
return new this.model(this.collection, { id: record.id }).prepareDestroyPermanently()
|
|
249
|
+
})
|
|
250
|
+
const newBuilds = Object.entries(builders).map(([id, builder]) => {
|
|
251
|
+
const existing = existingMap.get(id)
|
|
252
|
+
|
|
253
|
+
if (existing && existing._raw._status !== 'deleted') {
|
|
254
|
+
return existing.prepareUpdate(builder)
|
|
255
|
+
} else {
|
|
256
|
+
return this.collection.prepareCreate(record => {
|
|
257
|
+
const now = this.generateTimestamp()
|
|
258
|
+
|
|
259
|
+
record._raw.id = id
|
|
260
|
+
record._raw.created_at = now
|
|
261
|
+
record._raw.updated_at = now
|
|
262
|
+
builder(record)
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
await writer.batch(...destroyedBuilds)
|
|
268
|
+
await writer.batch(...newBuilds)
|
|
269
|
+
|
|
270
|
+
return newBuilds
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
this.emit('upserted', records)
|
|
274
|
+
|
|
275
|
+
this.pushUnsyncedWithRetry(span)
|
|
276
|
+
await this.ensurePersistence()
|
|
277
|
+
|
|
278
|
+
return records.map((record) => this.modelSerializer.toPlainObject(record))
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async upsertSomeTentative(builders: Record<RecordId, (record: TModel) => void>, span?: Span) {
|
|
283
|
+
return this.upsertSome(Object.fromEntries(Object.entries(builders).map(([id, builder]) => [id, record => {
|
|
284
|
+
builder(record)
|
|
285
|
+
record._raw._status = 'synced'
|
|
286
|
+
}])), span)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async upsertOne(id: RecordId, builder: (record: TModel) => void, span?: Span) {
|
|
290
|
+
return this.upsertSome({ [id]: builder }, span).then(r => r[0])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async upsertOneTentative(id: string, builder: (record: TModel) => void, span?: Span) {
|
|
294
|
+
return this.upsertSomeTentative({ [id]: builder }, span).then(r => r[0])
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async deleteOne(id: RecordId, span?: Span) {
|
|
298
|
+
return await this.runScope.abortable(async () => {
|
|
299
|
+
let record: TModel | null = null
|
|
300
|
+
|
|
301
|
+
await this.telemeterizedWrite(span, async () => {
|
|
302
|
+
const existing = await this.queryMaybeDeletedRecords(Q.where('id', id)).then(
|
|
303
|
+
(records) => records[0] || null
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if (existing && existing._raw._status !== 'deleted') {
|
|
307
|
+
await existing.markAsDeleted()
|
|
308
|
+
record = existing
|
|
309
|
+
} else {
|
|
310
|
+
record = await this.collection.create((record) => {
|
|
311
|
+
const now = this.generateTimestamp()
|
|
312
|
+
|
|
313
|
+
record._raw.id = id
|
|
314
|
+
record._raw.updated_at = now
|
|
315
|
+
record._raw._status = 'deleted'
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
this.emit('deleted', [id])
|
|
321
|
+
|
|
322
|
+
this.pushUnsyncedWithRetry(span)
|
|
323
|
+
await this.ensurePersistence()
|
|
324
|
+
|
|
325
|
+
return id
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async deleteSome(ids: RecordId[], span?: Span) {
|
|
330
|
+
return this.runScope.abortable(async () => {
|
|
331
|
+
await this.telemeterizedWrite(span, async writer => {
|
|
332
|
+
const existing = await this.queryRecords(Q.where('id', Q.oneOf(ids)))
|
|
333
|
+
|
|
334
|
+
await writer.batch(...existing.map(record => record.prepareMarkAsDeleted()))
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
this.emit('deleted', ids)
|
|
338
|
+
|
|
339
|
+
this.pushUnsyncedWithRetry(span)
|
|
340
|
+
await this.ensurePersistence()
|
|
341
|
+
|
|
342
|
+
return ids
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async importUpsert(recordRaws: TModel['_raw'][]) {
|
|
347
|
+
await this.runScope.abortable(async () => {
|
|
348
|
+
await this.telemeterizedWrite(undefined, async writer => {
|
|
349
|
+
const ids = recordRaws.map(r => r.id)
|
|
350
|
+
const existingMap = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))).then(records => {
|
|
351
|
+
return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const mainBatch = []
|
|
355
|
+
const destroyBatch = []
|
|
356
|
+
|
|
357
|
+
recordRaws.forEach(recordRaw => {
|
|
358
|
+
const existing = existingMap.get(recordRaw.id)
|
|
359
|
+
|
|
360
|
+
if (existing) {
|
|
361
|
+
if (existing._raw._status === 'deleted') {
|
|
362
|
+
if (recordRaw._status !== 'deleted') {
|
|
363
|
+
destroyBatch.push(new this.model(this.collection, { id: recordRaw.id }).prepareDestroyPermanently());
|
|
364
|
+
mainBatch.push(this.collection.prepareCreate((record) => {
|
|
365
|
+
Object.keys(recordRaw).forEach((key) => {
|
|
366
|
+
record._raw[key] = recordRaw[key]
|
|
367
|
+
})
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
if (recordRaw._status === 'deleted') {
|
|
372
|
+
mainBatch.push(existing.prepareMarkAsDeleted())
|
|
373
|
+
} else {
|
|
374
|
+
mainBatch.push(existing.prepareUpdate((record) => {
|
|
375
|
+
Object.keys(recordRaw).forEach((key) => {
|
|
376
|
+
record._raw[key] = recordRaw[key]
|
|
377
|
+
})
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
if (recordRaw._status === 'deleted') {
|
|
383
|
+
mainBatch.push(this.collection.prepareCreate((record) => {
|
|
384
|
+
const now = this.generateTimestamp()
|
|
385
|
+
|
|
386
|
+
record._raw.id = recordRaw.id
|
|
387
|
+
record._raw.updated_at = now
|
|
388
|
+
record._raw._status = 'deleted'
|
|
389
|
+
}))
|
|
390
|
+
} else {
|
|
391
|
+
mainBatch.push(this.collection.prepareCreate((record) => {
|
|
392
|
+
Object.keys(recordRaw).forEach((key) => {
|
|
393
|
+
record._raw[key] = recordRaw[key]
|
|
394
|
+
})
|
|
395
|
+
}))
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await writer.batch(...destroyBatch)
|
|
401
|
+
await writer.batch(...mainBatch)
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
async importDeletion(ids: RecordId[]) {
|
|
406
|
+
await this.runScope.abortable(async () => {
|
|
407
|
+
await this.telemeterizedWrite(undefined, async writer => {
|
|
408
|
+
const existingMap = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))).then(records => {
|
|
409
|
+
return records.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
const batch = []
|
|
413
|
+
|
|
414
|
+
ids.forEach(id => {
|
|
415
|
+
const existing = existingMap.get(id)
|
|
416
|
+
if (existing && existing._raw._status !== 'deleted') {
|
|
417
|
+
batch.push(existing.prepareMarkAsDeleted())
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await writer.batch(...batch)
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private async setLastFetchToken(token: SyncToken | null) {
|
|
427
|
+
await this.db.write(async () => {
|
|
428
|
+
if (token) {
|
|
429
|
+
const storedValue = await this.getLastFetchToken()
|
|
430
|
+
|
|
431
|
+
// avoids thrashing if we get and compare first before setting
|
|
432
|
+
if (storedValue !== token) {
|
|
433
|
+
this.telemetry.debug(`[store:${this.model.table}] Setting last fetch token: ${token}`)
|
|
434
|
+
return this.db.localStorage.set(this.lastFetchTokenKey, token)
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
this.telemetry.debug(`[store:${this.model.table}] Removing last fetch token`)
|
|
438
|
+
return this.db.localStorage.remove(this.lastFetchTokenKey)
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private async pullRecordsWithRetry(span?: Span) {
|
|
444
|
+
dropThrottle({ state: this.pullThrottleState, deferOnce: true }, () =>
|
|
445
|
+
this.retry.request(
|
|
446
|
+
{ name: `pull:${this.model.table}`, op: 'pull', parentSpan: span },
|
|
447
|
+
(span) => this.executePull(span)
|
|
448
|
+
)
|
|
449
|
+
)()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async pushUnsyncedWithRetry(span?: Span) {
|
|
453
|
+
const records = await this.queryMaybeDeletedRecords(Q.where('_status', Q.notEq('synced')))
|
|
454
|
+
|
|
455
|
+
if (records.length) {
|
|
456
|
+
this.pushCoalescer.push(
|
|
457
|
+
records,
|
|
458
|
+
queueThrottle({ state: this.pushThrottleState }, () => {
|
|
459
|
+
return this.retry.request(
|
|
460
|
+
{ name: `push:${this.model.table}`, op: 'push', parentSpan: span },
|
|
461
|
+
(span) => this.executePush(records, span)
|
|
462
|
+
)
|
|
463
|
+
})
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async executePull(span?: Span) {
|
|
469
|
+
return this.telemetry.trace(
|
|
470
|
+
{
|
|
471
|
+
name: `pull:${this.model.table}:run`,
|
|
472
|
+
op: 'pull:run',
|
|
473
|
+
attributes: { table: this.model.table },
|
|
474
|
+
parentSpan: span,
|
|
475
|
+
},
|
|
476
|
+
async (pullSpan) => {
|
|
477
|
+
const lastFetchToken = await this.getLastFetchToken()
|
|
478
|
+
|
|
479
|
+
const response = await this.telemetry.trace(
|
|
480
|
+
{
|
|
481
|
+
name: `pull:${this.model.table}:run:fetch`,
|
|
482
|
+
op: 'pull:run:fetch',
|
|
483
|
+
attributes: { lastFetchToken: lastFetchToken ?? undefined },
|
|
484
|
+
parentSpan: pullSpan,
|
|
485
|
+
},
|
|
486
|
+
() => this.puller(this.context.session, lastFetchToken, this.runScope.signal)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if (response.ok) {
|
|
490
|
+
await this.writeEntries(response.entries, !response.previousToken, pullSpan)
|
|
491
|
+
await this.setLastFetchToken(response.token)
|
|
492
|
+
|
|
493
|
+
this.emit('pullCompleted')
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return response
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private async executePush(records: TModel[], parentSpan?: Span) {
|
|
502
|
+
return this.telemetry.trace(
|
|
503
|
+
{
|
|
504
|
+
name: `push:${this.model.table}`,
|
|
505
|
+
op: 'push:run',
|
|
506
|
+
attributes: { table: this.model.table },
|
|
507
|
+
parentSpan,
|
|
508
|
+
},
|
|
509
|
+
async (pushSpan) => {
|
|
510
|
+
const payload = this.generatePushPayload(records)
|
|
511
|
+
|
|
512
|
+
const response = await this.telemetry.trace(
|
|
513
|
+
{
|
|
514
|
+
name: `push:${this.model.table}:run:fetch`,
|
|
515
|
+
op: 'push:run:fetch',
|
|
516
|
+
parentSpan: pushSpan,
|
|
517
|
+
},
|
|
518
|
+
() => this.pusher(this.context.session, payload, this.runScope.signal)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if (response.ok) {
|
|
522
|
+
const successfulResults = response.results.filter((result) => result.type === 'success')
|
|
523
|
+
const successfulEntries = successfulResults.map((result) => result.entry)
|
|
524
|
+
await this.writeEntries(successfulEntries, false, pushSpan)
|
|
525
|
+
|
|
526
|
+
this.emit('pushCompleted')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return response
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private generatePushPayload(records: TModel[]) {
|
|
535
|
+
const entries = records.map((rec) => {
|
|
536
|
+
if (rec._raw._status === 'deleted') {
|
|
537
|
+
return {
|
|
538
|
+
record: null,
|
|
539
|
+
meta: { ids: { id: rec._raw.id }, deleted: true as true },
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
const record = this.rawSerializer.toPlainObject(rec)
|
|
543
|
+
return {
|
|
544
|
+
record,
|
|
545
|
+
meta: { ids: { id: rec._raw.id }, deleted: false as false },
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
return {
|
|
550
|
+
entries,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async queryRecords(...args: Q.Clause[]) {
|
|
555
|
+
return await this.collection.query(...args).fetch()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async queryRecordIds(...args: Q.Clause[]) {
|
|
559
|
+
return await this.collection.query(...args).fetchIds()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async queryRecord(...args: Q.Clause[]) {
|
|
563
|
+
return await this.collection.query(Q.take(1), ...args).fetch().then((records) => records[0] as TModel | null)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async queryRecordId(...args: Q.Clause[]) {
|
|
567
|
+
return await this.collection.query(Q.take(1), ...args).fetchIds().then((ids) => ids[0] as RecordId | null)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async findRecord(id: RecordId) {
|
|
571
|
+
return this.collection
|
|
572
|
+
.query(Q.where('id', id))
|
|
573
|
+
.fetch()
|
|
574
|
+
.then((records) => records[0] as TModel | null)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private async queryMaybeDeletedRecords(...args: Q.Clause[]) {
|
|
578
|
+
const serializedQuery = this.collection.query(...args).serialize()
|
|
579
|
+
const adjustedQuery = {
|
|
580
|
+
...serializedQuery,
|
|
581
|
+
description: {
|
|
582
|
+
...serializedQuery.description,
|
|
583
|
+
where: serializedQuery.description.where.filter(
|
|
584
|
+
(w) =>
|
|
585
|
+
!(
|
|
586
|
+
w.type === 'where' &&
|
|
587
|
+
w.left === '_status' &&
|
|
588
|
+
w.comparison &&
|
|
589
|
+
w.comparison.operator === 'notEq' &&
|
|
590
|
+
w.comparison.right &&
|
|
591
|
+
'value' in w.comparison.right &&
|
|
592
|
+
w.comparison.right.value === 'deleted'
|
|
593
|
+
)
|
|
594
|
+
),
|
|
595
|
+
},
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return (await this.db.adapter.unsafeQueryRaw(adjustedQuery)).map((raw) => {
|
|
599
|
+
return new this.model(
|
|
600
|
+
this.collection,
|
|
601
|
+
sanitizedRaw(raw, this.db.schema.tables[this.collection.table])
|
|
602
|
+
)
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Avoid lazy persistence to IndexedDB
|
|
607
|
+
// to eliminate data loss risk due to tab close/crash before flush to IndexedDB
|
|
608
|
+
// https://github.com/Nozbe/WatermelonDB/issues/1329
|
|
609
|
+
// NOTE: does NOT go through watermelon, so this might be dangerous?
|
|
610
|
+
private async ensurePersistence() {
|
|
611
|
+
return new Promise<void>((resolve, reject) => {
|
|
612
|
+
if (this.isLokiAdapter(this.db.adapter.underlyingAdapter)) {
|
|
613
|
+
this.db.adapter.underlyingAdapter._driver.loki.saveDatabase((err) => {
|
|
614
|
+
if (err) {
|
|
615
|
+
reject(err)
|
|
616
|
+
} else {
|
|
617
|
+
resolve()
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
} else {
|
|
621
|
+
resolve()
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private telemeterizedWrite<T>(
|
|
627
|
+
parentSpan: Span | undefined,
|
|
628
|
+
work: (writer: WriterInterface) => Promise<T>
|
|
629
|
+
): Promise<T> {
|
|
630
|
+
return this.telemetry.trace(
|
|
631
|
+
{ name: `write:${this.model.table}`, op: 'write', parentSpan },
|
|
632
|
+
(writeSpan) => {
|
|
633
|
+
return this.db.write(writer =>
|
|
634
|
+
this.telemetry.trace(
|
|
635
|
+
{
|
|
636
|
+
name: `write:generate:${this.model.table}`,
|
|
637
|
+
op: 'write:generate',
|
|
638
|
+
parentSpan: writeSpan,
|
|
639
|
+
},
|
|
640
|
+
() => work(writer)
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private async writeEntries(entries: SyncEntry[], freshSync: boolean = false, parentSpan?: Span) {
|
|
648
|
+
await this.runScope.abortable(async () => {
|
|
649
|
+
return this.telemeterizedWrite(parentSpan, async (writer) => {
|
|
650
|
+
const batches = await this.buildWriteBatchesFromEntries(entries, freshSync)
|
|
651
|
+
|
|
652
|
+
for (const batch of batches) {
|
|
653
|
+
if (batch.length) {
|
|
654
|
+
await writer.batch(...batch)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private async buildWriteBatchesFromEntries(entries: SyncEntry[], freshSync: boolean) {
|
|
662
|
+
// if this is a fresh sync and there are no existing records, we can skip more sophisticated conflict resolution
|
|
663
|
+
if (freshSync) {
|
|
664
|
+
if ((await this.queryMaybeDeletedRecords()).length === 0) {
|
|
665
|
+
const resolver = new Resolver(this.resolverComparator)
|
|
666
|
+
entries
|
|
667
|
+
.filter((e) => !e.meta.lifecycle.deleted_at)
|
|
668
|
+
.forEach((entry) => resolver.againstNone(entry))
|
|
669
|
+
|
|
670
|
+
return this.prepareRecords(resolver.result)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const entryIds = entries.map((e) => e.meta.ids.id)
|
|
675
|
+
const existingRecordsMap = new Map<RecordId, TModel>()
|
|
676
|
+
|
|
677
|
+
const existingRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(entryIds)))
|
|
678
|
+
existingRecords.forEach((record) => existingRecordsMap.set(record.id, record))
|
|
679
|
+
|
|
680
|
+
const resolver = new Resolver(this.resolverComparator)
|
|
681
|
+
|
|
682
|
+
entries.forEach((entry) => {
|
|
683
|
+
const existing = existingRecordsMap.get(entry.meta.ids.id)
|
|
684
|
+
if (existing) {
|
|
685
|
+
switch (existing._raw._status) {
|
|
686
|
+
case 'created':
|
|
687
|
+
resolver.againstCreated(existing, entry)
|
|
688
|
+
break
|
|
689
|
+
|
|
690
|
+
case 'updated':
|
|
691
|
+
resolver.againstUpdated(existing, entry)
|
|
692
|
+
break
|
|
693
|
+
|
|
694
|
+
case 'synced':
|
|
695
|
+
resolver.againstSynced(existing, entry)
|
|
696
|
+
break
|
|
697
|
+
|
|
698
|
+
case 'deleted':
|
|
699
|
+
resolver.againstDeleted(existing, entry)
|
|
700
|
+
break
|
|
701
|
+
|
|
702
|
+
default:
|
|
703
|
+
this.telemetry.error(`[store:${this.model.table}] Unknown record status`, {
|
|
704
|
+
status: existing._raw._status,
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
resolver.againstNone(entry)
|
|
709
|
+
}
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
return this.prepareRecords(resolver.result)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private prepareRecords(result: SyncResolution) {
|
|
716
|
+
if (Object.values(result).find((c) => c.length)) {
|
|
717
|
+
this.telemetry.debug(`[store:${this.model.table}] Writing changes`, { changes: result })
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const destroyedBuilds = result.idsForDestroy.map((id) => {
|
|
721
|
+
return new this.model(this.collection, { id }).prepareDestroyPermanently()
|
|
722
|
+
})
|
|
723
|
+
const createdBuilds = result.entriesForCreate.map((entry) => {
|
|
724
|
+
return this.collection.prepareCreate((r) => {
|
|
725
|
+
Object.entries(entry.record!).forEach(([key, value]) => {
|
|
726
|
+
if (key === 'id') r._raw.id = value.toString()
|
|
727
|
+
else r._raw[key] = value
|
|
728
|
+
})
|
|
729
|
+
r._raw['created_at'] = entry.meta.lifecycle.created_at
|
|
730
|
+
r._raw['updated_at'] = entry.meta.lifecycle.updated_at
|
|
731
|
+
|
|
732
|
+
r._raw._status = 'synced'
|
|
733
|
+
r._raw._changed = ''
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
const updatedBuilds = result.tuplesForUpdate.map(([record, entry]) => {
|
|
737
|
+
return record.prepareUpdate((r) => {
|
|
738
|
+
Object.entries(entry.record!).forEach(([key, value]) => {
|
|
739
|
+
if (key !== 'id') r._raw[key] = value
|
|
740
|
+
})
|
|
741
|
+
r._raw['created_at'] = entry.meta.lifecycle.created_at
|
|
742
|
+
r._raw['updated_at'] = entry.meta.lifecycle.updated_at
|
|
743
|
+
|
|
744
|
+
r._raw._status = 'synced'
|
|
745
|
+
r._raw._changed = ''
|
|
746
|
+
})
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const restoreDestroyBuilds = result.tuplesForRestore.map(([model]) => {
|
|
750
|
+
return new this.model(this.collection, { id: model.id }).prepareDestroyPermanently()
|
|
751
|
+
})
|
|
752
|
+
const restoreCreateBuilds = result.tuplesForRestore.map(([_model, entry]) => {
|
|
753
|
+
return this.collection.prepareCreate((r) => {
|
|
754
|
+
Object.entries(entry.record!).forEach(([key, value]) => {
|
|
755
|
+
if (key === 'id') r._raw.id = value.toString()
|
|
756
|
+
else r._raw[key] = value
|
|
757
|
+
})
|
|
758
|
+
r._raw['created_at'] = entry.meta.lifecycle.created_at
|
|
759
|
+
r._raw['updated_at'] = entry.meta.lifecycle.updated_at
|
|
760
|
+
|
|
761
|
+
r._raw._status = 'synced'
|
|
762
|
+
r._raw._changed = ''
|
|
763
|
+
})
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
return [
|
|
767
|
+
[...destroyedBuilds, ...createdBuilds, ...updatedBuilds, ...restoreDestroyBuilds],
|
|
768
|
+
[...restoreCreateBuilds],
|
|
769
|
+
]
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private generateTimestamp() {
|
|
773
|
+
return Date.now() as EpochMs
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private isLokiAdapter(adapter: any): adapter is LokiJSAdapter {
|
|
777
|
+
return adapter._driver && 'loki' in adapter._driver
|
|
778
|
+
}
|
|
779
|
+
}
|