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 +8 -0
- package/package.json +1 -1
- package/src/services/sync/adapters/factory.ts +4 -4
- package/src/services/sync/adapters/lokijs.ts +53 -1
- package/src/services/sync/debug.ts +42 -0
- package/src/services/sync/index.ts +2 -2
- package/src/services/sync/manager.ts +7 -0
- package/src/services/sync/repositories/content-progress.ts +9 -11
- package/src/services/sync/run-scope.ts +6 -12
- package/src/services/sync/store/index.ts +26 -7
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
|
@@ -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?:
|
|
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?:
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|