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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.93.0",
3
+ "version": "2.93.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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().capture(wrapped)
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().capture(wrapped);
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
- export type SyncPushResponse = SyncPushSuccessResponse | SyncPushFailureResponse
28
-
29
- type SyncPushSuccessResponse = SyncPushResponseBase & {
28
+ type SyncPushSuccessResponse = SyncResponseBase & {
30
29
  ok: true
31
30
  results: SyncStorePushResult[]
32
31
  }
33
- type SyncPushFailureResponse = SyncPushResponseBase & {
32
+ type SyncPushFetchFailureResponse = SyncResponseBase & {
34
33
  ok: false,
35
- originalError: Error
34
+ isRetryable: boolean
36
35
  }
37
- interface SyncPushResponseBase extends SyncResponseBase {
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 = SyncPullResponseBase & {
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 = SyncPullResponseBase & {
72
+ type SyncPullFailureResponse = SyncResponseBase & {
73
73
  ok: false,
74
- originalError: Error
74
+ isRetryable: boolean
75
75
  }
76
- interface SyncPullResponseBase extends SyncResponseBase {
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 performFetch(request)
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 performFetch(request)
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
- likes: ContentLikesRepository
19
- contentProgress: ContentProgressRepository
20
- practices: PracticesRepository
21
- practiceDayNotes: PracticeDayNotesRepository
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
- target[prop] = new ContentLikesRepository(manager.getStore(ContentLike))
32
- break
42
+ cache.likes = new ContentLikesRepository(manager.getStore(ContentLike));
43
+ break;
33
44
  case 'contentProgress':
34
- target[prop] = new ContentProgressRepository(manager.getStore(ContentProgress))
35
- break
45
+ cache.contentProgress = new ContentProgressRepository(manager.getStore(ContentProgress));
46
+ break;
36
47
  case 'practices':
37
- target[prop] = new PracticesRepository(manager.getStore(Practice))
38
- break
48
+ cache.practices = new PracticesRepository(manager.getStore(Practice));
49
+ break;
39
50
  case 'practiceDayNotes':
40
- target[prop] = new PracticeDayNotesRepository(manager.getStore(PracticeDayNote))
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 target[prop]
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
- this.scheduleBackoff()
60
- if (attempt >= this.MAX_ATTEMPTS) return result
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.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)
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
- } else {
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
 
@@ -75,7 +75,6 @@ export class SyncTelemetry {
75
75
  }
76
76
  } : undefined)
77
77
 
78
-
79
78
  this._ignoreConsole = true
80
79
  this.error(err.message)
81
80
  this._ignoreConsole = false
@@ -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().shouldIgnoreConsole()) {
7
+ if (event.logger === 'console' && SyncTelemetry.getInstance()?.shouldIgnoreConsole()) {
8
8
  return null
9
9
  }
10
10