musora-content-services 2.150.0 → 2.151.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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.151.0](https://github.com/railroadmedia/musora-content-services/compare/v2.149.0...v2.151.0) (2026-04-08)
6
+
7
+
8
+ ### Features
9
+
10
+ * recovers user id if clobbered by Safari ITP ([#892](https://github.com/railroadmedia/musora-content-services/issues/892)) ([338476d](https://github.com/railroadmedia/musora-content-services/commit/338476d69025140f3a93b30e0ae63d194d185185))
11
+ * throw specific error after abort; protect getLastFetchToken ([#905](https://github.com/railroadmedia/musora-content-services/issues/905)) ([7c084e4](https://github.com/railroadmedia/musora-content-services/commit/7c084e4b351e26f8464f568d658e855f84e31951))
12
+
5
13
  ## [2.150.0](https://github.com/railroadmedia/musora-content-services/compare/v2.149.0...v2.150.0) (2026-04-08)
6
14
 
7
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.150.0",
3
+ "version": "2.151.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ import { SyncError } from '../errors'
4
4
  import { globalConfig } from '../../config.js'
5
5
 
6
6
  import type { default as SQLiteAdapter, SQLiteExtensions } from './sqlite'
7
- import type LokiJSAdapter from './lokijs'
7
+ import type { default as LokiJSAdapter, LokiExtensions } from './lokijs'
8
8
 
9
9
  export type DatabaseAdapter = SQLiteAdapter | LokiJSAdapter
10
10
 
@@ -13,10 +13,10 @@ type LokiJSAdapterOptions = ConstructorParameters<typeof LokiJSAdapter>[0]
13
13
 
14
14
  type DatabaseAdapterOptions = SQLiteAdapterOptions & LokiJSAdapterOptions
15
15
 
16
- export default function syncAdapterFactory<T extends DatabaseAdapter>(
17
- AdapterClass: new (options: DatabaseAdapterOptions, extensions?: SQLiteExtensions) => T,
16
+ export default function syncAdapterFactory<T extends DatabaseAdapter, E = SQLiteExtensions | LokiExtensions>(
17
+ AdapterClass: new (options: DatabaseAdapterOptions, extensions?: E) => T,
18
18
  opts: Omit<DatabaseAdapterOptions, 'schema' | 'migrations'>,
19
- extensions?: SQLiteExtensions
19
+ extensions?: E
20
20
  ): (userScope: SyncUserScope) => T {
21
21
  // IMPORTANT: we rely on namespaced databases to prevent data clobbering
22
22
  // when localStorage.userId somehow changes outside of an explicit, app-managed logout
@@ -1,10 +1,62 @@
1
1
  import { SyncTelemetry } from '../telemetry'
2
2
 
3
3
  import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
4
- export { LokiJSAdapter as default }
5
4
 
6
5
  import { deleteDatabase, lokiFatalError } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
7
6
 
7
+ export type LokiExtensions = {
8
+ onPersistenceError?: (err: Error) => void
9
+ }
10
+
11
+ export default class LokiPersistenceErrorAwareAdapter extends LokiJSAdapter {
12
+ constructor(options: any, extensions: LokiExtensions = {}) {
13
+ super(options);
14
+ const that = this;
15
+
16
+ // Schedule save override right at end of setup hook right after `_driver` is ready
17
+ const setupDispatchCallback = this._dispatcher._pendingCalls[0].callback;
18
+ this._dispatcher._pendingCalls[0].callback = function() {
19
+ that._overrideSaveDatabase(extensions.onPersistenceError);
20
+ setupDispatchCallback.apply(that, arguments);
21
+ }
22
+ }
23
+
24
+ _overrideSaveDatabase(onPersistenceError?: (err: Error) => void) {
25
+ const driver = this._driver
26
+ const persistenceAdapter = driver.loki.persistenceAdapter
27
+ const oldSaveDatabase = persistenceAdapter.saveDatabase;
28
+
29
+ persistenceAdapter.saveDatabase = function(...args: any[]) {
30
+ const callback = args[2];
31
+ oldSaveDatabase.call(persistenceAdapter, args[0], args[1], function(err: Error | null) {
32
+ if (err && err.name === 'InvalidStateError' && err.message.includes('database connection is closing')) {
33
+ // triggers new connection on next save
34
+ if (persistenceAdapter.idb && !persistenceAdapter.idb._disableReconnect) {
35
+ persistenceAdapter.idb.close()
36
+ persistenceAdapter.idb = null
37
+ }
38
+
39
+ // retry once
40
+ oldSaveDatabase.call(persistenceAdapter, args[0], args[1], function(err: Error | null) {
41
+ if (err && err.name === 'InvalidStateError' && err.message.includes('database connection is closing')) {
42
+ // Don't set _isBroken - that prevents us from being to trigger onPersistenceError in the future
43
+ // driver._isBroken = true
44
+
45
+ onPersistenceError?.(err)
46
+ }
47
+
48
+ callback(err);
49
+ })
50
+
51
+ return
52
+ }
53
+
54
+ callback(err);
55
+ })
56
+ }
57
+ }
58
+ }
59
+
8
60
  /**
9
61
  * Mute impending driver errors that are expected after sync adapter failure
10
62
  */
@@ -0,0 +1,42 @@
1
+ import { Database } from '@nozbe/watermelondb'
2
+
3
+ /**
4
+ * Debug utilities for sync system testing and development.
5
+ * These functions should only be used in development/testing environments.
6
+ */
7
+
8
+ /**
9
+ * Completely purges the WatermelonDB database by deleting the underlying IndexedDB.
10
+ * WARNING: This will permanently delete ALL data in the database.
11
+ * Only works with Loki adapter.
12
+ */
13
+ export function purgeDatabase(database: Database): void {
14
+ const driver = (database.adapter.underlyingAdapter as any)._driver as any
15
+ if (!('loki' in driver)) {
16
+ throw new Error('Only Loki databases are purgeable')
17
+ }
18
+
19
+ const idb = driver.loki.persistenceAdapter.idb
20
+ idb.close()
21
+ window.indexedDB.deleteDatabase(idb.name)
22
+ window.indexedDB.deleteDatabase('WatermelonIDBChecker')
23
+ }
24
+
25
+ /**
26
+ * Disconnects the database connection, optionally disabling automatic reconnection.
27
+ * Useful for testing offline scenarios or connection handling.
28
+ * Only works with Loki adapter.
29
+ */
30
+ export function disconnectDatabase(database: Database, disableReconnect = false): void {
31
+ const driver = (database.adapter.underlyingAdapter as any)._driver as any
32
+ if (!('loki' in driver)) {
33
+ throw new Error('Only Loki databases are disconnectable')
34
+ }
35
+
36
+ const idb = driver.loki.persistenceAdapter.idb
37
+ idb.close()
38
+
39
+ if (disableReconnect) {
40
+ idb._disableReconnect = true
41
+ }
42
+ }
@@ -1,10 +1,10 @@
1
1
  import './telemetry/index'
2
2
 
3
- import { Q } from "@nozbe/watermelondb"
3
+ import { Q, type RecordId } from "@nozbe/watermelondb"
4
4
  import { type ModelSerialized } from "./serializers"
5
5
  import BaseModel from "./models/Base"
6
6
 
7
- export type SyncUserScope = { initialId: number, getCurrentId: () => number }
7
+ export type SyncUserScope = { initialId: number, getCurrentId: () => number | null, fetchCurrentId?: () => Promise<number | null> }
8
8
 
9
9
  export { default as db } from './repository-proxy'
10
10
  export { Q }
@@ -63,6 +63,7 @@ export default class SyncManager {
63
63
  private abortWritesToDatabase?: (adapter: DatabaseAdapter) => Promise<void>
64
64
 
65
65
  private teardownPromise: Promise<void> | null = null
66
+ private database: Database | null = null
66
67
 
67
68
  constructor(
68
69
  userScope: SyncUserScope,
@@ -144,6 +145,7 @@ export default class SyncManager {
144
145
  { name: 'db:init', op: 'db', attributes: { ...this.context.session.toJSON() } },
145
146
  () => this.initDatabase(this.userScope)
146
147
  )
148
+ this.database = database
147
149
 
148
150
  Object.entries(this.storeConfigsRegistry).forEach(([table, storeConfig]) => {
149
151
  this.storesRegistry[table] = this.createStore(storeConfig, database)
@@ -234,6 +236,7 @@ export default class SyncManager {
234
236
  database.adapter.underlyingAdapter
235
237
  )
236
238
  }
239
+ this.database = null
237
240
  })
238
241
  }
239
242
 
@@ -267,6 +270,10 @@ export default class SyncManager {
267
270
  return teardown
268
271
  }
269
272
 
273
+ getDatabase() {
274
+ return this.database
275
+ }
276
+
270
277
  getStore<TModel extends BaseModel>(model: ModelClass<TModel>) {
271
278
  const store = this.storesRegistry[model.table]
272
279
  if (!store) {
@@ -166,7 +166,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
166
166
  return await this.readSome(ids)
167
167
  }
168
168
 
169
- recordProgress(
169
+ async recordProgress(
170
170
  contentId: number,
171
171
  collection: CollectionParameter | null,
172
172
  progressPct: number,
@@ -179,7 +179,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
179
179
  accessedDirectly = false
180
180
  }
181
181
 
182
- const result = this.upsertOne(id, (r) => {
182
+ const result = await this.upsertOne(id, (r) => {
183
183
  r.content_id = contentId
184
184
  r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
185
185
  r.collection_id = collection?.id ?? COLLECTION_ID_SELF
@@ -202,13 +202,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
202
202
 
203
203
  }, { skipPush })
204
204
 
205
- // Emit event AFTER database write completes
206
- result.then(() => {
207
- return Promise.all([
208
- import('../../progress-events'),
209
- import('../../config')
210
- ])
211
- }).then(([progressEventsModule, { globalConfig }]) => {
205
+ // Emit event AFTER database write completes (don't let emit failures affect the result)
206
+ Promise.all([
207
+ import('../../progress-events'),
208
+ import('../../config')
209
+ ]).then(([progressEventsModule, { globalConfig }]) => {
212
210
  progressEventsModule.emitProgressSaved({
213
211
  userId: Number(globalConfig.railcontentConfig?.userId) || 0,
214
212
  contentId,
@@ -227,7 +225,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
227
225
  return result
228
226
  }
229
227
 
230
- recordProgressMany(
228
+ async recordProgressMany(
231
229
  contentProgresses: Record<string, number>, // Accept plain object
232
230
  collection: CollectionParameter | null,
233
231
  metadata: Record<string, MetadataParameter>,
@@ -257,7 +255,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
257
255
  },
258
256
  ])
259
257
  )
260
- return this.upsertSome(data, { skipPush })
258
+ return await this.upsertSome(data, { skipPush })
261
259
 
262
260
  //todo add event emitting for bulk updates?
263
261
  }
@@ -15,18 +15,12 @@ export default class SyncRunScope {
15
15
  this.abortController.abort(reason)
16
16
  }
17
17
 
18
+ // simply rejects if aborted, otherwise runs the function
19
+ // does NOT attempt to pass abort signal to the function
18
20
  abortable<T>(fn: () => Promise<T>): Promise<T> {
19
- return new Promise((resolve, reject) => {
20
- if (this.signal.aborted) {
21
- reject(new SyncAbortError('Operation aborted', { reason: this.signal.reason }))
22
- return
23
- }
24
-
25
- fn().then(resolve).catch(reject)
26
-
27
- this.signal.addEventListener('abort', () => {
28
- reject(this.signal.reason)
29
- })
30
- })
21
+ if (this.signal.aborted) {
22
+ reject(new SyncAbortError('Operation aborted', { reason: this.signal.reason }))
23
+ }
24
+ return fn()
31
25
  }
32
26
  }
@@ -588,7 +588,20 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
588
588
 
589
589
  if (response.ok) {
590
590
  const initialId = this.userScope.initialId
591
- const currentId = this.userScope.getCurrentId()
591
+ let currentId = this.userScope.getCurrentId()
592
+
593
+ if (currentId === null && this.userScope.fetchCurrentId) {
594
+ try {
595
+ currentId = await this.userScope.fetchCurrentId()
596
+ } catch (error) {
597
+ throw new SyncError('Intended user ID does not match after fetchCurrentId failed', {
598
+ intendedUserId: response.intendedUserId,
599
+ initialUserId: initialId,
600
+ currentUserId: currentId,
601
+ fetchError: error
602
+ })
603
+ }
604
+ }
592
605
 
593
606
  if (response.intendedUserId !== initialId || response.intendedUserId !== currentId) {
594
607
  throw new SyncError('Intended user ID does not match', {
@@ -789,18 +802,24 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
789
802
  /**
790
803
  * Performs telemetry-wrapped write, crucially checking if the user has changed
791
804
  */
792
- private paranoidWrite<T>(
805
+ private async paranoidWrite<T>(
793
806
  parentSpan: Span | undefined,
794
807
  work: (writer: WriterInterface, span: Span) => Promise<T>,
795
808
  spanName: 'sync.write' | 'sync.ack' | 'sync.cleanup' = 'sync.write'
796
809
  ): Promise<T> {
797
810
  const initialId = this.userScope.initialId
798
- const currentId = this.userScope.getCurrentId()
811
+ let currentId = this.userScope.getCurrentId()
812
+
813
+ if (currentId === null && this.userScope.fetchCurrentId) {
814
+ try {
815
+ currentId = await this.userScope.fetchCurrentId()
816
+ } catch {
817
+ throw new SyncError('Aborted cross-user write operation after fetchCurrentId failed', { initialId, currentId })
818
+ }
819
+ }
820
+
799
821
  if (initialId !== currentId) {
800
- throw new SyncError('Aborted cross-user write operation', {
801
- initialId,
802
- currentId,
803
- })
822
+ throw new SyncError('Aborted cross-user write operation', { initialId, currentId })
804
823
  }
805
824
 
806
825
  return this.telemetry.trace(