musora-content-services 2.122.3 → 2.122.4

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