musora-content-services 2.93.0 → 2.93.1
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 +2 -0
- package/package.json +1 -1
- package/src/services/sync/errors/boundary.ts +2 -2
- package/src/services/sync/fetch.ts +32 -28
- package/src/services/sync/manager.ts +13 -2
- package/src/services/sync/repository-proxy.ts +34 -19
- package/src/services/sync/retry.ts +8 -2
- package/src/services/sync/store/index.ts +14 -12
- package/src/services/sync/telemetry/index.ts +0 -1
- package/src/services/sync/telemetry/sampling.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
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.93.1](https://github.com/railroadmedia/musora-content-services/compare/v2.93.0...v2.93.1) (2025-12-02)
|
|
6
|
+
|
|
5
7
|
## [2.93.0](https://github.com/railroadmedia/musora-content-services/compare/v2.92.7...v2.93.0) (2025-12-02)
|
|
6
8
|
|
|
7
9
|
|
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@ export function inBoundary<T, TContext extends Record<string, any>>(fn: (context
|
|
|
29
29
|
if (result instanceof Promise) {
|
|
30
30
|
return result.catch((err: unknown) => {
|
|
31
31
|
const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
|
|
32
|
-
SyncTelemetry.getInstance()
|
|
32
|
+
SyncTelemetry.getInstance()?.capture(wrapped)
|
|
33
33
|
|
|
34
34
|
throw wrapped;
|
|
35
35
|
});
|
|
@@ -38,7 +38,7 @@ export function inBoundary<T, TContext extends Record<string, any>>(fn: (context
|
|
|
38
38
|
return result;
|
|
39
39
|
} catch (err: unknown) {
|
|
40
40
|
const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
|
|
41
|
-
SyncTelemetry.getInstance()
|
|
41
|
+
SyncTelemetry.getInstance()?.capture(wrapped);
|
|
42
42
|
|
|
43
43
|
throw wrapped;
|
|
44
44
|
}
|
|
@@ -23,19 +23,19 @@ interface RawPushResponse {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export type SyncResponse = SyncPushResponse | SyncPullResponse
|
|
26
|
+
export type SyncPushResponse = SyncPushSuccessResponse | SyncPushFetchFailureResponse | SyncPushFailureResponse
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
type SyncPushSuccessResponse = SyncPushResponseBase & {
|
|
28
|
+
type SyncPushSuccessResponse = SyncResponseBase & {
|
|
30
29
|
ok: true
|
|
31
30
|
results: SyncStorePushResult[]
|
|
32
31
|
}
|
|
33
|
-
type
|
|
32
|
+
type SyncPushFetchFailureResponse = SyncResponseBase & {
|
|
34
33
|
ok: false,
|
|
35
|
-
|
|
34
|
+
isRetryable: boolean
|
|
36
35
|
}
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
type SyncPushFailureResponse = SyncResponseBase & {
|
|
37
|
+
ok: false,
|
|
38
|
+
originalError: Error
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
|
|
@@ -61,20 +61,21 @@ interface SyncStorePushResultBase {
|
|
|
61
61
|
type: 'success' | 'failure'
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export type SyncPullResponse = SyncPullSuccessResponse | SyncPullFailureResponse
|
|
64
|
+
export type SyncPullResponse = SyncPullSuccessResponse | SyncPullFailureResponse | SyncPullFetchFailureResponse
|
|
65
65
|
|
|
66
|
-
type SyncPullSuccessResponse =
|
|
66
|
+
type SyncPullSuccessResponse = SyncResponseBase & {
|
|
67
67
|
ok: true
|
|
68
68
|
entries: SyncEntry[]
|
|
69
69
|
token: SyncToken
|
|
70
70
|
previousToken: SyncToken | null
|
|
71
71
|
}
|
|
72
|
-
type SyncPullFailureResponse =
|
|
72
|
+
type SyncPullFailureResponse = SyncResponseBase & {
|
|
73
73
|
ok: false,
|
|
74
|
-
|
|
74
|
+
isRetryable: boolean
|
|
75
75
|
}
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
type SyncPullFetchFailureResponse = SyncResponseBase & {
|
|
77
|
+
ok: false,
|
|
78
|
+
originalError: Error
|
|
78
79
|
}
|
|
79
80
|
export interface SyncResponseBase {
|
|
80
81
|
ok: boolean
|
|
@@ -141,11 +142,18 @@ export function handlePull(callback: (session: BaseSessionProvider) => Request)
|
|
|
141
142
|
|
|
142
143
|
let response: Response | null = null
|
|
143
144
|
try {
|
|
144
|
-
response = await
|
|
145
|
+
response = await fetch(request)
|
|
145
146
|
} catch (e) {
|
|
146
147
|
return {
|
|
147
148
|
ok: false,
|
|
148
|
-
originalError: e
|
|
149
|
+
originalError: e as Error
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (response.ok === false) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
isRetryable: (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
159
|
|
|
@@ -180,11 +188,18 @@ export function handlePush(callback: (session: BaseSessionProvider) => Request)
|
|
|
180
188
|
|
|
181
189
|
let response: Response | null = null
|
|
182
190
|
try {
|
|
183
|
-
response = await
|
|
191
|
+
response = await fetch(request)
|
|
184
192
|
} catch (e) {
|
|
185
193
|
return {
|
|
186
194
|
ok: false,
|
|
187
|
-
originalError: e
|
|
195
|
+
originalError: e as Error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (response.ok === false) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
isRetryable: (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
|
|
188
203
|
}
|
|
189
204
|
}
|
|
190
205
|
|
|
@@ -198,17 +213,6 @@ export function handlePush(callback: (session: BaseSessionProvider) => Request)
|
|
|
198
213
|
}
|
|
199
214
|
}
|
|
200
215
|
|
|
201
|
-
async function performFetch(request: Request) {
|
|
202
|
-
const response = await fetch(request)
|
|
203
|
-
const isRetryable = (response.status >= 500 && response.status < 504) || response.status === 429 || response.status === 408
|
|
204
|
-
|
|
205
|
-
if (isRetryable) {
|
|
206
|
-
throw new Error(`Server returned ${response.status}`)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return response
|
|
210
|
-
}
|
|
211
|
-
|
|
212
216
|
function serializePullUrlQuery(url: string, fetchToken: SyncToken | null) {
|
|
213
217
|
const queryString = url.replace(/^[^?]*\??/, '');
|
|
214
218
|
const searchParams = new URLSearchParams(queryString);
|
|
@@ -14,6 +14,7 @@ import { inBoundary } from './errors/boundary'
|
|
|
14
14
|
import createStoresFromConfig from './store-configs'
|
|
15
15
|
|
|
16
16
|
export default class SyncManager {
|
|
17
|
+
private static counter = 0
|
|
17
18
|
private static instance: SyncManager | null = null
|
|
18
19
|
|
|
19
20
|
public static assignAndSetupInstance(instance: SyncManager) {
|
|
@@ -23,8 +24,8 @@ export default class SyncManager {
|
|
|
23
24
|
SyncManager.instance = instance
|
|
24
25
|
const teardown = instance.setup()
|
|
25
26
|
return async () => {
|
|
26
|
-
await teardown()
|
|
27
27
|
SyncManager.instance = null
|
|
28
|
+
await teardown()
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -35,6 +36,7 @@ export default class SyncManager {
|
|
|
35
36
|
return SyncManager.instance
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
private id: string
|
|
38
40
|
public telemetry: SyncTelemetry
|
|
39
41
|
private database: Database
|
|
40
42
|
private context: SyncContext
|
|
@@ -45,10 +47,12 @@ export default class SyncManager {
|
|
|
45
47
|
private safetyMap: { stores: SyncStore<any>[]; mechanisms: (() => void)[] }[]
|
|
46
48
|
|
|
47
49
|
constructor(context: SyncContext, initDatabase: () => Database) {
|
|
50
|
+
this.id = (SyncManager.counter++).toString()
|
|
51
|
+
|
|
48
52
|
this.telemetry = SyncTelemetry.getInstance()!
|
|
49
53
|
this.context = context
|
|
50
54
|
|
|
51
|
-
this.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase))
|
|
55
|
+
this.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase)) // todo - can cause undefined??
|
|
52
56
|
|
|
53
57
|
this.runScope = new SyncRunScope()
|
|
54
58
|
this.retry = new SyncRetry(this.context, this.telemetry)
|
|
@@ -59,6 +63,13 @@ export default class SyncManager {
|
|
|
59
63
|
this.safetyMap = []
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Useful as a cache key (if user logs in and out multiple times, creating multiple managers)
|
|
68
|
+
*/
|
|
69
|
+
getId() {
|
|
70
|
+
return this.id
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
|
|
63
74
|
return new SyncStore<TModel>(config, this.context, this.database, this.retry, this.runScope, this.telemetry)
|
|
64
75
|
}
|
|
@@ -14,35 +14,50 @@ import {
|
|
|
14
14
|
PracticeDayNote
|
|
15
15
|
} from "./models"
|
|
16
16
|
|
|
17
|
+
|
|
17
18
|
interface SyncRepositories {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
likes: ContentLikesRepository;
|
|
20
|
+
contentProgress: ContentProgressRepository;
|
|
21
|
+
practices: PracticesRepository;
|
|
22
|
+
practiceDayNotes: PracticeDayNotesRepository;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export default new Proxy({} as SyncRepositories, {
|
|
25
|
-
get(target: SyncRepositories, prop: keyof SyncRepositories) {
|
|
26
|
-
if (!target[prop]) {
|
|
27
|
-
const manager = SyncManager.getInstance()
|
|
28
25
|
|
|
26
|
+
// internal cache for repositories, keyed by managerId and property name
|
|
27
|
+
const repoCache: Record<string, Partial<SyncRepositories>> = {};
|
|
28
|
+
|
|
29
|
+
const proxy = new Proxy({} as SyncRepositories, {
|
|
30
|
+
get(_target, prop: keyof SyncRepositories) {
|
|
31
|
+
const manager = SyncManager.getInstance();
|
|
32
|
+
const managerId = manager.getId();
|
|
33
|
+
|
|
34
|
+
if (!repoCache[managerId]) {
|
|
35
|
+
repoCache[managerId] = {};
|
|
36
|
+
}
|
|
37
|
+
const cache = repoCache[managerId];
|
|
38
|
+
|
|
39
|
+
if (!cache[prop]) {
|
|
29
40
|
switch (prop) {
|
|
30
41
|
case 'likes':
|
|
31
|
-
|
|
32
|
-
break
|
|
42
|
+
cache.likes = new ContentLikesRepository(manager.getStore(ContentLike));
|
|
43
|
+
break;
|
|
33
44
|
case 'contentProgress':
|
|
34
|
-
|
|
35
|
-
break
|
|
45
|
+
cache.contentProgress = new ContentProgressRepository(manager.getStore(ContentProgress));
|
|
46
|
+
break;
|
|
36
47
|
case 'practices':
|
|
37
|
-
|
|
38
|
-
break
|
|
48
|
+
cache.practices = new PracticesRepository(manager.getStore(Practice));
|
|
49
|
+
break;
|
|
39
50
|
case 'practiceDayNotes':
|
|
40
|
-
|
|
41
|
-
break
|
|
51
|
+
cache.practiceDayNotes = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote));
|
|
52
|
+
break;
|
|
42
53
|
default:
|
|
43
|
-
throw new SyncError(`Repository '${prop}' not found`)
|
|
54
|
+
throw new SyncError(`Repository '${String(prop)}' not found`);
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
|
-
return
|
|
57
|
+
return cache[prop];
|
|
47
58
|
}
|
|
48
|
-
})
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export default proxy;
|
|
62
|
+
|
|
63
|
+
|
|
@@ -56,8 +56,14 @@ export default class SyncRetry {
|
|
|
56
56
|
this.resetBackoff()
|
|
57
57
|
return result
|
|
58
58
|
} else {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const isRetryable = 'isRetryable' in result ? result.isRetryable : false
|
|
60
|
+
|
|
61
|
+
if (isRetryable) {
|
|
62
|
+
this.scheduleBackoff()
|
|
63
|
+
if (attempt >= this.MAX_ATTEMPTS) return result
|
|
64
|
+
} else {
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
}
|
|
@@ -424,19 +424,21 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
424
424
|
}
|
|
425
425
|
|
|
426
426
|
private async setLastFetchToken(token: SyncToken | null) {
|
|
427
|
-
await this.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
427
|
+
await this.runScope.abortable(async () => {
|
|
428
|
+
await this.db.write(async () => {
|
|
429
|
+
if (token) {
|
|
430
|
+
const storedValue = await this.getLastFetchToken()
|
|
431
|
+
|
|
432
|
+
// avoids thrashing if we get and compare first before setting
|
|
433
|
+
if (storedValue !== token) {
|
|
434
|
+
this.telemetry.debug(`[store:${this.model.table}] Setting last fetch token: ${token}`)
|
|
435
|
+
return this.db.localStorage.set(this.lastFetchTokenKey, token)
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
this.telemetry.debug(`[store:${this.model.table}] Removing last fetch token`)
|
|
439
|
+
return this.db.localStorage.remove(this.lastFetchTokenKey)
|
|
435
440
|
}
|
|
436
|
-
}
|
|
437
|
-
this.telemetry.debug(`[store:${this.model.table}] Removing last fetch token`)
|
|
438
|
-
return this.db.localStorage.remove(this.lastFetchTokenKey)
|
|
439
|
-
}
|
|
441
|
+
})
|
|
440
442
|
})
|
|
441
443
|
}
|
|
442
444
|
|
|
@@ -4,7 +4,7 @@ import { SyncError } from '../errors'
|
|
|
4
4
|
type ReturnsUndefined<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T> | undefined
|
|
5
5
|
|
|
6
6
|
export const syncSentryBeforeSend: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSend']>> = (event, hint) => {
|
|
7
|
-
if (event.logger === 'console' && SyncTelemetry.getInstance()
|
|
7
|
+
if (event.logger === 'console' && SyncTelemetry.getInstance()?.shouldIgnoreConsole()) {
|
|
8
8
|
return null
|
|
9
9
|
}
|
|
10
10
|
|