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.
Files changed (139) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Forums.html +2 -2
  4. package/docs/Gamification.html +2 -2
  5. package/docs/TestUser.html +2 -2
  6. package/docs/UserManagementSystem.html +2 -2
  7. package/docs/api_types.js.html +2 -2
  8. package/docs/config.js.html +2 -2
  9. package/docs/content-org_content-org.js.html +2 -2
  10. package/docs/content-org_guided-courses.ts.html +2 -2
  11. package/docs/content-org_learning-paths.ts.html +52 -40
  12. package/docs/content-org_playlists-types.js.html +2 -2
  13. package/docs/content-org_playlists.js.html +2 -2
  14. package/docs/content.js.html +2 -2
  15. package/docs/content_artist.ts.html +2 -2
  16. package/docs/content_genre.ts.html +2 -2
  17. package/docs/content_instructor.ts.html +2 -2
  18. package/docs/forums_categories.ts.html +2 -2
  19. package/docs/forums_forums.ts.html +2 -2
  20. package/docs/forums_posts.ts.html +2 -2
  21. package/docs/forums_threads.ts.html +2 -2
  22. package/docs/gamification_awards.ts.html +2 -2
  23. package/docs/gamification_gamification.js.html +2 -2
  24. package/docs/global.html +2 -2
  25. package/docs/index.html +2 -2
  26. package/docs/liveTesting.ts.html +2 -2
  27. package/docs/module-Accounts.html +2 -2
  28. package/docs/module-Artist.html +2 -2
  29. package/docs/module-Awards.html +2 -2
  30. package/docs/module-Config.html +2 -2
  31. package/docs/module-Content-Services-V2.html +2 -2
  32. package/docs/module-Forums.html +2 -2
  33. package/docs/module-Genre.html +2 -2
  34. package/docs/module-GuidedCourses.html +2 -2
  35. package/docs/module-Instructor.html +2 -2
  36. package/docs/module-Interests.html +2 -2
  37. package/docs/module-LearningPaths.html +269 -143
  38. package/docs/module-Onboarding.html +3 -3
  39. package/docs/module-Payments.html +2 -2
  40. package/docs/module-Permissions.html +2 -2
  41. package/docs/module-Playlists.html +2 -2
  42. package/docs/module-ProgressRow.html +2 -2
  43. package/docs/module-Railcontent-Services.html +34 -893
  44. package/docs/module-Sanity-Services.html +2 -2
  45. package/docs/module-Sessions.html +2 -2
  46. package/docs/module-UserActivity.html +70 -116
  47. package/docs/module-UserChat.html +2 -2
  48. package/docs/module-UserManagement.html +2 -2
  49. package/docs/module-UserMemberships.html +2 -2
  50. package/docs/module-UserNotifications.html +2 -2
  51. package/docs/module-UserProfile.html +2 -2
  52. package/docs/progress-row_method-card.js.html +3 -2
  53. package/docs/railcontent.js.html +14 -137
  54. package/docs/sanity.js.html +2 -2
  55. package/docs/userActivity.js.html +85 -150
  56. package/docs/user_account.ts.html +2 -2
  57. package/docs/user_chat.js.html +2 -2
  58. package/docs/user_interests.js.html +2 -2
  59. package/docs/user_management.js.html +2 -2
  60. package/docs/user_memberships.ts.html +2 -2
  61. package/docs/user_notifications.js.html +2 -2
  62. package/docs/user_onboarding.ts.html +10 -6
  63. package/docs/user_payments.ts.html +2 -2
  64. package/docs/user_permissions.js.html +2 -2
  65. package/docs/user_profile.js.html +2 -2
  66. package/docs/user_sessions.js.html +2 -2
  67. package/docs/user_types.js.html +2 -2
  68. package/docs/user_user-management-system.js.html +2 -2
  69. package/package.json +11 -3
  70. package/src/contentTypeConfig.js +6 -0
  71. package/src/index.d.ts +7 -31
  72. package/src/index.js +10 -34
  73. package/src/services/content-org/learning-paths.ts +31 -0
  74. package/src/services/contentAggregator.js +2 -2
  75. package/src/services/contentLikes.js +6 -39
  76. package/src/services/contentProgress.js +181 -479
  77. package/src/services/dataContext.js +0 -2
  78. package/src/services/progress-row/method-card.js +1 -0
  79. package/src/services/railcontent.js +12 -135
  80. package/src/services/sentry/.indexignore +0 -0
  81. package/src/services/sentry/index.ts +23 -0
  82. package/src/services/sync/.indexignore +0 -0
  83. package/src/services/sync/adapters/factory.ts +26 -0
  84. package/src/services/sync/adapters/lokijs.ts +1 -0
  85. package/src/services/sync/adapters/sqlite.ts +1 -0
  86. package/src/services/sync/concurrency-safety.ts +4 -0
  87. package/src/services/sync/context/index.ts +43 -0
  88. package/src/services/sync/context/providers/base.ts +4 -0
  89. package/src/services/sync/context/providers/connectivity.ts +14 -0
  90. package/src/services/sync/context/providers/durability.ts +5 -0
  91. package/src/services/sync/context/providers/index.ts +5 -0
  92. package/src/services/sync/context/providers/session.ts +8 -0
  93. package/src/services/sync/context/providers/tabs.ts +18 -0
  94. package/src/services/sync/context/providers/visibility.ts +14 -0
  95. package/src/services/sync/database/factory.ts +10 -0
  96. package/src/services/sync/errors/boundary.ts +45 -0
  97. package/src/services/sync/errors/index.ts +49 -0
  98. package/src/services/sync/fetch.ts +310 -0
  99. package/src/services/sync/index.ts +80 -0
  100. package/src/services/sync/manager.ts +139 -0
  101. package/src/services/sync/models/Base.ts +47 -0
  102. package/src/services/sync/models/ContentLike.ts +16 -0
  103. package/src/services/sync/models/ContentProgress.ts +69 -0
  104. package/src/services/sync/models/Practice.ts +72 -0
  105. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  106. package/src/services/sync/models/index.ts +4 -0
  107. package/src/services/sync/repositories/base.ts +247 -0
  108. package/src/services/sync/repositories/content-likes.ts +26 -0
  109. package/src/services/sync/repositories/content-progress.ts +160 -0
  110. package/src/services/sync/repositories/index.ts +4 -0
  111. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  112. package/src/services/sync/repositories/practices.ts +52 -0
  113. package/src/services/sync/repository-proxy.ts +48 -0
  114. package/src/services/sync/resolver.ts +84 -0
  115. package/src/services/sync/retry.ts +88 -0
  116. package/src/services/sync/run-scope.ts +30 -0
  117. package/src/services/sync/schema/index.ts +66 -0
  118. package/src/services/sync/serializers/index.ts +2 -0
  119. package/src/services/sync/serializers/model.ts +32 -0
  120. package/src/services/sync/serializers/raw.ts +21 -0
  121. package/src/services/sync/store/index.ts +779 -0
  122. package/src/services/sync/store/push-coalescer.ts +57 -0
  123. package/src/services/sync/store-configs.ts +41 -0
  124. package/src/services/sync/strategies/base.ts +21 -0
  125. package/src/services/sync/strategies/index.ts +12 -0
  126. package/src/services/sync/strategies/initial.ts +11 -0
  127. package/src/services/sync/strategies/polling.ts +54 -0
  128. package/src/services/sync/telemetry/index.ts +140 -0
  129. package/src/services/sync/telemetry/sampling.ts +91 -0
  130. package/src/services/sync/utils/event-emitter.ts +24 -0
  131. package/src/services/sync/utils/index.ts +1 -0
  132. package/src/services/sync/utils/throttle.ts +93 -0
  133. package/src/services/sync/utils/timers.ts +9 -0
  134. package/src/services/userActivity.js +83 -148
  135. package/test/contentProgress.test.js +6 -39
  136. package/test/live/contentProgressLive.test.js +2 -31
  137. package/tools/generate-index.cjs +10 -4
  138. package/.claude/settings.local.json +0 -8
  139. 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
+ }