musora-content-services 2.108.0 → 2.111.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CLAUDE.md +2 -2
  3. package/package.json +1 -1
  4. package/src/contentMetaData.js +0 -5
  5. package/src/contentTypeConfig.js +11 -73
  6. package/src/index.d.ts +0 -20
  7. package/src/index.js +0 -20
  8. package/src/services/content.js +1 -1
  9. package/src/services/contentAggregator.js +1 -4
  10. package/src/services/railcontent.js +0 -163
  11. package/src/services/sanity.js +258 -7
  12. package/src/services/sync/adapters/factory.ts +6 -6
  13. package/src/services/sync/adapters/lokijs.ts +174 -1
  14. package/src/services/sync/context/providers/durability.ts +1 -0
  15. package/src/services/sync/database/factory.ts +12 -5
  16. package/src/services/sync/effects/index.ts +6 -0
  17. package/src/services/sync/effects/logout-warning.ts +47 -0
  18. package/src/services/sync/errors/boundary.ts +4 -6
  19. package/src/services/sync/errors/index.ts +16 -0
  20. package/src/services/sync/fetch.ts +5 -5
  21. package/src/services/sync/manager.ts +80 -40
  22. package/src/services/sync/repositories/base.ts +1 -8
  23. package/src/services/sync/retry.ts +4 -4
  24. package/src/services/sync/store/index.ts +34 -31
  25. package/src/services/sync/store/push-coalescer.ts +3 -3
  26. package/src/services/sync/store-configs.ts +10 -8
  27. package/src/services/sync/telemetry/flood-prevention.ts +27 -0
  28. package/src/services/sync/telemetry/index.ts +71 -9
  29. package/src/services/sync/telemetry/sampling.ts +2 -6
  30. package/src/services/user/types.d.ts +0 -7
  31. package/test/sync/adapter.ts +2 -34
  32. package/test/sync/initialize-sync-manager.js +8 -25
  33. package/.claude/settings.local.json +0 -9
  34. package/src/services/sync/concurrency-safety.ts +0 -4
@@ -1,5 +1,6 @@
1
1
  import BaseModel from './models/Base'
2
2
  import { Database } from '@nozbe/watermelondb'
3
+ import { DatabaseAdapter } from '@nozbe/watermelondb/adapters/type'
3
4
  import SyncRunScope from './run-scope'
4
5
  import { SyncStrategy } from './strategies'
5
6
  import { default as SyncStore, SyncStoreConfig } from './store'
@@ -8,10 +9,9 @@ import { ModelClass } from './index'
8
9
  import SyncRetry from './retry'
9
10
  import SyncContext from './context'
10
11
  import { SyncError } from './errors'
11
- import { SyncConcurrencySafetyMechanism } from './concurrency-safety'
12
+ import { SyncEffect } from './effects'
12
13
  import { SyncTelemetry } from './telemetry/index'
13
- import { inBoundary } from './errors/boundary'
14
- import createStoresFromConfig from './store-configs'
14
+ import createStoreConfigs from './store-configs'
15
15
  import { contentProgressObserver } from '../awards/internal/content-progress-observer'
16
16
 
17
17
  export default class SyncManager {
@@ -22,11 +22,16 @@ export default class SyncManager {
22
22
  if (SyncManager.instance) {
23
23
  throw new SyncError('SyncManager already initialized')
24
24
  }
25
+
25
26
  SyncManager.instance = instance
26
27
  const teardown = instance.setup()
27
- return async () => {
28
+
29
+ return (force = false) => {
28
30
  SyncManager.instance = null
29
- await teardown()
31
+ return teardown(force).catch(error => {
32
+ SyncManager.instance = instance // restore instance on teardown failure
33
+ throw error
34
+ })
30
35
  }
31
36
  }
32
37
 
@@ -43,29 +48,34 @@ export default class SyncManager {
43
48
 
44
49
  private id: string
45
50
  public telemetry: SyncTelemetry
46
- private database: Database
47
51
  private context: SyncContext
52
+ private storeConfigsRegistry: Record<string, SyncStoreConfig<any>>
48
53
  private storesRegistry: Record<string, SyncStore<any>>
49
54
  private runScope: SyncRunScope
50
55
  private retry: SyncRetry
51
- private strategyMap: { stores: SyncStore<any>[]; strategies: SyncStrategy[] }[]
52
- private safetyMap: { stores: SyncStore<any>[]; mechanisms: (() => void)[] }[]
56
+ private strategyMap: { models: ModelClass[]; strategies: SyncStrategy[] }[]
57
+ private effectMap: { models: ModelClass[]; effects: SyncEffect[] }[]
58
+
59
+ private initDatabase: () => Database
60
+ private destroyDatabase?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>
53
61
 
54
- constructor(context: SyncContext, initDatabase: () => Database) {
62
+ constructor(context: SyncContext, initDatabase: () => Database, destroyDatabase?: (dbName: string, adapter: DatabaseAdapter) => Promise<void>) {
55
63
  this.id = (SyncManager.counter++).toString()
56
64
 
57
65
  this.telemetry = SyncTelemetry.getInstance()!
58
66
  this.context = context
59
67
 
60
- this.database = this.telemetry.trace({ name: 'db:init' }, () => inBoundary(initDatabase)) // todo - can cause undefined??
68
+ this.initDatabase = initDatabase
69
+ this.destroyDatabase = destroyDatabase
70
+
71
+ this.storeConfigsRegistry = this.registerStoreConfigs(createStoreConfigs())
72
+ this.storesRegistry = {}
61
73
 
62
74
  this.runScope = new SyncRunScope()
63
75
  this.retry = new SyncRetry(this.context, this.telemetry)
64
76
 
65
- this.storesRegistry = this.registerStores(createStoresFromConfig(this.createStore.bind(this)))
66
-
67
77
  this.strategyMap = []
68
- this.safetyMap = []
78
+ this.effectMap = []
69
79
  }
70
80
 
71
81
  /**
@@ -75,27 +85,23 @@ export default class SyncManager {
75
85
  return this.id
76
86
  }
77
87
 
78
- createStore<TModel extends BaseModel>(config: SyncStoreConfig<TModel>) {
79
- return new SyncStore<TModel>(
88
+ createStore(config: SyncStoreConfig, database: Database) {
89
+ return new SyncStore(
80
90
  config,
81
91
  this.context,
82
- this.database,
92
+ database,
83
93
  this.retry,
84
94
  this.runScope,
85
95
  this.telemetry
86
96
  )
87
97
  }
88
98
 
89
- registerStores<TModel extends BaseModel>(stores: SyncStore<TModel>[]) {
99
+ registerStoreConfigs(stores: SyncStoreConfig[]) {
90
100
  return Object.fromEntries(
91
101
  stores.map((store) => {
92
102
  return [store.model.table, store]
93
103
  })
94
- ) as Record<string, SyncStore<TModel>>
95
- }
96
-
97
- storesForModels(models: ModelClass[]) {
98
- return models.map((model) => this.storesRegistry[model.table])
104
+ ) as Record<string, SyncStoreConfig>
99
105
  }
100
106
 
101
107
  createStrategy<T extends SyncStrategy, U extends any[]>(
@@ -105,24 +111,32 @@ export default class SyncManager {
105
111
  return new strategyClass(this.context, ...args)
106
112
  }
107
113
 
108
- syncStoresWithStrategies(stores: SyncStore<any>[], strategies: SyncStrategy[]) {
109
- this.strategyMap.push({ stores, strategies })
114
+ registerStrategies(models: ModelClass[], strategies: SyncStrategy[]) {
115
+ this.strategyMap.push({ models, strategies })
110
116
  }
111
117
 
112
- protectStores(stores: SyncStore<any>[], mechanisms: SyncConcurrencySafetyMechanism[]) {
113
- const teardowns = mechanisms.map((mechanism) => mechanism(this.context, stores))
114
- this.safetyMap.push({ stores, mechanisms: teardowns })
118
+ registerEffects(models: ModelClass[], effects: SyncEffect[]) {
119
+ this.effectMap.push({ models, effects })
115
120
  }
116
121
 
117
122
  setup() {
118
123
  this.telemetry.debug('[SyncManager] Setting up')
119
124
 
125
+ // can fail synchronously immediately (e.g., schema/migration validation errors)
126
+ // or asynchronously (e.g., indexedDB errors synchronously OR asynchronously (!))
127
+ const database = this.telemetry.trace({ name: 'db:init' }, this.initDatabase)
128
+
129
+ Object.entries(this.storeConfigsRegistry).forEach(([table, storeConfig]) => {
130
+ this.storesRegistry[table] = this.createStore(storeConfig, database)
131
+ })
132
+
120
133
  this.context.start()
121
134
  this.retry.start()
122
135
 
123
- this.strategyMap.forEach(({ stores, strategies }) => {
136
+ this.strategyMap.forEach(({ models, strategies }) => {
124
137
  strategies.forEach((strategy) => {
125
- stores.forEach((store) => {
138
+ models.forEach((model) => {
139
+ const store = this.storesRegistry[model.table]
126
140
  strategy.onTrigger(store, (reason) => {
127
141
  store.requestSync(reason)
128
142
  })
@@ -131,22 +145,48 @@ export default class SyncManager {
131
145
  })
132
146
  })
133
147
 
134
- contentProgressObserver.start(this.database).catch((error) => {
148
+ const effectTeardowns = this.effectMap.flatMap(({ models, effects }) => {
149
+ return effects.map((effect) => effect(this.context, models.map(model => this.storesRegistry[model.table])))
150
+ });
151
+
152
+ contentProgressObserver.start(database).catch((error) => {
135
153
  this.telemetry.error('[SyncManager] Failed to start contentProgressObserver', error)
136
154
  })
137
155
 
138
- const teardown = async () => {
156
+ const teardown = async (force = false) => {
139
157
  this.telemetry.debug('[SyncManager] Tearing down')
140
- this.runScope.abort()
141
- this.strategyMap.forEach(({ strategies }) =>
142
- strategies.forEach((strategy) => strategy.stop())
143
- )
144
- this.safetyMap.forEach(({ mechanisms }) => mechanisms.forEach((mechanism) => mechanism()))
145
- contentProgressObserver.stop()
146
- this.retry.stop()
147
- this.context.stop()
148
- await this.database.write(() => this.database.unsafeResetDatabase())
158
+
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())
164
+ }
165
+ }
166
+
167
+ try {
168
+ this.runScope.abort()
169
+ this.strategyMap.forEach(({ strategies }) => strategies.forEach((strategy) => strategy.stop()))
170
+ effectTeardowns.forEach((teardown) => teardown())
171
+ this.retry.stop()
172
+ this.context.stop()
173
+
174
+ contentProgressObserver.stop()
175
+ } catch (error) {
176
+ // capture, but don't rethrow
177
+ this.telemetry.capture(error)
178
+ }
179
+
180
+ try {
181
+ return clear(force);
182
+ } catch (error) {
183
+ if (!force) {
184
+ return clear(true);
185
+ }
186
+ throw error
187
+ }
149
188
  }
189
+
150
190
  return teardown
151
191
  }
152
192
 
@@ -89,13 +89,6 @@ export default class SyncRepository<TModel extends BaseModel> {
89
89
  )
90
90
  }
91
91
 
92
- protected async upsertOneRemote(id: RecordId, builder: (record: TModel) => void) {
93
- return this.store.telemetry.trace(
94
- { name: `upsertOneRemote:${this.store.model.table}`, op: 'upsert' },
95
- (span) => this._respondToRemoteWriteOne(() => this.store.upsertOneRemote(id, builder, span), id, span)
96
- )
97
- }
98
-
99
92
  protected async upsertOne(id: RecordId, builder: (record: TModel) => void, { skipPush = false } = {}) {
100
93
  return this.store.telemetry.trace(
101
94
  { name: `upsertOne:${this.store.model.table}`, op: 'upsert' },
@@ -213,7 +206,7 @@ export default class SyncRepository<TModel extends BaseModel> {
213
206
  const result: SyncReadDTO<TModel, T> = {
214
207
  data,
215
208
  status: pull?.ok ? 'fresh' : 'stale',
216
- pullStatus: pull?.ok ? 'success' : 'failure',
209
+ pullStatus: pull ? (pull.ok ? 'success' : 'failure') : null,
217
210
  lastFetchToken: fetchToken,
218
211
  }
219
212
  return result
@@ -1,5 +1,5 @@
1
1
  import SyncContext from "./context"
2
- import { SyncResponse } from "./fetch"
2
+ import { SyncResponse, SyncPushResponse } from "./fetch"
3
3
  import { SyncTelemetry, Span, StartSpanOptions } from "./telemetry/index"
4
4
 
5
5
  export default class SyncRetry {
@@ -32,7 +32,7 @@ export default class SyncRetry {
32
32
  * Runs the given syncFn with automatic retries.
33
33
  * Returns the first successful result or the last failed result after retries.
34
34
  */
35
- async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T | void>) {
35
+ async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T>) {
36
36
  let attempt = 0
37
37
 
38
38
  while (true) {
@@ -47,7 +47,7 @@ export default class SyncRetry {
47
47
  const result = await this.telemetry.trace(spanOptions, span => {
48
48
  if (!this.context.connectivity.getValue()) {
49
49
  this.telemetry.debug('[Retry] No connectivity - skipping')
50
- return { ok: false } as T
50
+ return { ok: false, failureType: 'fetch', isRetryable: false } as T
51
51
  }
52
52
 
53
53
  return syncFn(span)
@@ -55,7 +55,7 @@ export default class SyncRetry {
55
55
 
56
56
  if (!result) return result
57
57
 
58
- if (result.ok) {
58
+ if (result.ok === true) {
59
59
  this.resetBackoff()
60
60
  return result
61
61
  } else {
@@ -1,7 +1,7 @@
1
1
  import { Database, Q, type Collection, type RecordId } from '@nozbe/watermelondb'
2
2
  import { RawSerializer, ModelSerializer } from '../serializers'
3
3
  import { ModelClass, SyncToken, SyncEntry, SyncContext, EpochMs } from '..'
4
- import { SyncPullResponse, SyncPushResponse, SyncPushFailureResponse, PushPayload } from '../fetch'
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'
7
7
  import EventEmitter from '../utils/event-emitter'
@@ -29,7 +29,7 @@ type SyncPush = (
29
29
  signal: AbortSignal
30
30
  ) => Promise<SyncPushResponse>
31
31
 
32
- export type SyncStoreConfig<TModel extends BaseModel> = {
32
+ export type SyncStoreConfig<TModel extends BaseModel = BaseModel> = {
33
33
  model: ModelClass<TModel>
34
34
  comparator?: TModel extends BaseModel ? SyncResolverComparator<TModel> : SyncResolverComparator
35
35
  pull: SyncPull
@@ -122,6 +122,17 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
122
122
  }, { table: this.model.table, reason })
123
123
  }
124
124
 
125
+ async requestPush(reason: string) {
126
+ inBoundary(ctx => {
127
+ this.telemetry.trace(
128
+ { name: `sync:${this.model.table}`, op: 'push', attributes: ctx },
129
+ async span => {
130
+ await this.pushUnsyncedWithRetry(span)
131
+ }
132
+ )
133
+ }, { table: this.model.table, reason })
134
+ }
135
+
125
136
  async getLastFetchToken() {
126
137
  return (await this.db.localStorage.get<SyncToken | null>(this.lastFetchTokenKey)) ?? null
127
138
  }
@@ -210,31 +221,6 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
210
221
  })
211
222
  }
212
223
 
213
- async upsertOneRemote(id: RecordId, builder: (record: TModel) => void, span?: Span) {
214
- return await this.runScope.abortable(async () => {
215
- let record: TModel
216
- const existing = await this.queryMaybeDeletedRecords(Q.where('id', id)).then(r => r[0] || null)
217
-
218
- if (existing) {
219
- existing._isEditing = true
220
- builder(existing)
221
- existing._isEditing = false
222
- record = existing
223
- } else {
224
- const attrs = new this.model(this.collection, { id })
225
- attrs._isEditing = true
226
- builder(attrs)
227
- attrs._isEditing = false
228
- record = this.collection.disposableFromDirtyRaw(attrs._raw)
229
- }
230
-
231
- return await this.pushCoalescer.push(
232
- [record],
233
- () => this.executePush([record], span)
234
- )
235
- })
236
- }
237
-
238
224
  async upsertSome(builders: Record<RecordId, (record: TModel) => void>, span?: Span, { skipPush = false } = {}) {
239
225
  if (Object.keys(builders).length === 0) return []
240
226
 
@@ -501,9 +487,11 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
501
487
  const currentRecords = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(recordIds)))
502
488
  const recordsToPush = currentRecords.filter(r => r._raw.updated_at <= (updatedAtMap.get(r.id) || 0))
503
489
 
504
- if (recordsToPush.length) {
505
- return this.executePush(recordsToPush, span)
490
+ if (!recordsToPush.length) {
491
+ return { ok: true, results: [] }
506
492
  }
493
+
494
+ return this.executePush(recordsToPush, span)
507
495
  }
508
496
  )
509
497
  })
@@ -514,7 +502,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
514
502
  private async executePull(span?: Span) {
515
503
  if (!this.context.connectivity.getValue()) {
516
504
  this.telemetry.debug('[Retry] No connectivity - skipping')
517
- return { ok: false } as SyncPushFailureResponse
505
+ return { ok: false, failureType: 'fetch', isRetryable: false } as SyncPullFetchFailureResponse
518
506
  }
519
507
 
520
508
  return this.telemetry.trace(
@@ -570,7 +558,22 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
570
558
  )
571
559
 
572
560
  if (response.ok) {
573
- const successfulResults = response.results.filter((result) => result.type === 'success')
561
+ const [failedResults, successfulResults] = response.results.reduce((acc, result) => {
562
+ if (result.type === 'success') {
563
+ acc[1].push(result)
564
+ } else {
565
+ acc[0].push(result)
566
+ }
567
+ return acc
568
+ }, [[], []] as [SyncStorePushResultFailure[], SyncStorePushResultSuccess[]])
569
+
570
+ if (failedResults.length) {
571
+ this.telemetry.warn(
572
+ `[store:${this.model.table}] Push completed with failed records`,
573
+ { results: failedResults }
574
+ )
575
+ }
576
+
574
577
  const successfulEntries = successfulResults.map((result) => result.entry)
575
578
  await this.writeEntries(successfulEntries, false, pushSpan)
576
579
 
@@ -4,7 +4,7 @@ import { EpochMs } from ".."
4
4
  import { SyncPushResponse } from "../fetch"
5
5
 
6
6
  type PushIntent = {
7
- promise: Promise<void | SyncPushResponse>
7
+ promise: Promise<SyncPushResponse>
8
8
  records: {
9
9
  id: RecordId
10
10
  updatedAt: EpochMs
@@ -18,7 +18,7 @@ export default class PushCoalescer {
18
18
  this.intents = []
19
19
  }
20
20
 
21
- push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<void | SyncPushResponse>) {
21
+ push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<SyncPushResponse>) {
22
22
  const found = this.find(records)
23
23
 
24
24
  if (found) {
@@ -28,7 +28,7 @@ export default class PushCoalescer {
28
28
  return this.add(pusher(records), records)
29
29
  }
30
30
 
31
- private add(promise: Promise<void | SyncPushResponse>, records: BaseModel[]) {
31
+ private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
32
32
  const intent = {
33
33
  promise,
34
34
  records: records.map(record => ({
@@ -2,18 +2,20 @@ import { SyncStoreConfig } from "./store"
2
2
  import { ContentLike, ContentProgress, Practice, UserAwardProgress, PracticeDayNote } from "./models"
3
3
  import { handlePull, handlePush, makeFetchRequest } from "./fetch"
4
4
 
5
- import type SyncStore from "./store"
6
5
  import type BaseModel from "./models/Base"
7
6
 
8
- export default function createStoresFromConfig(createStore: <TModel extends BaseModel>(config: SyncStoreConfig<TModel>) => SyncStore<TModel>) {
7
+ // keeps type-safety in each entry
8
+ const c = <TModel extends BaseModel>(config: SyncStoreConfig<TModel>) => config
9
+
10
+ export default function createStoresFromConfig() {
9
11
  return [
10
- createStore({
12
+ c({
11
13
  model: ContentLike,
12
14
  pull: handlePull(makeFetchRequest('/api/content/v1/user/likes')),
13
15
  push: handlePush(makeFetchRequest('/api/content/v1/user/likes', { method: 'POST' })),
14
16
  }),
15
17
 
16
- createStore({
18
+ c({
17
19
  model: ContentProgress,
18
20
  comparator: (server, local) => {
19
21
  if (server.record.progress_percent === 0 || local.progress_percent === 0) {
@@ -26,22 +28,22 @@ export default function createStoresFromConfig(createStore: <TModel extends Base
26
28
  push: handlePush(makeFetchRequest('/content/user/progress', { method: 'POST' })),
27
29
  }),
28
30
 
29
- createStore({
31
+ c({
30
32
  model: Practice,
31
33
  pull: handlePull(makeFetchRequest('/api/user/practices/v1')),
32
34
  push: handlePush(makeFetchRequest('/api/user/practices/v1', { method: 'POST' })),
33
35
  }),
34
36
 
35
- createStore({
37
+ c({
36
38
  model: PracticeDayNote,
37
39
  pull: handlePull(makeFetchRequest('/api/user/practices/v1/notes')),
38
40
  push: handlePush(makeFetchRequest('/api/user/practices/v1/notes', { method: 'POST' })),
39
41
  }),
40
42
 
41
- createStore({
43
+ c({
42
44
  model: UserAwardProgress,
43
45
  pull: handlePull(makeFetchRequest('/api/content/v1/user/awards')),
44
46
  push: handlePush(makeFetchRequest('/api/content/v1/user/awards', { method: 'POST' })),
45
47
  })
46
- ] as unknown as SyncStore<BaseModel>[]
48
+ ]
47
49
  }
@@ -0,0 +1,27 @@
1
+ import { SyncTelemetry, type SentryBrowserOptions } from ".";
2
+
3
+ export function errorHandler(event: ErrorEvent) {
4
+ if (SyncTelemetry.getInstance()?.shouldIgnoreException(event.error)) {
5
+ event.preventDefault() // doesn't reliably work in all browsers - error still logged to console
6
+ }
7
+ }
8
+
9
+ export function rejectionHandler(event: PromiseRejectionEvent) {
10
+ if (SyncTelemetry.getInstance()?.shouldIgnoreRejection(event.reason)) {
11
+ event.preventDefault()
12
+ }
13
+ }
14
+
15
+ type ReturnsUndefined<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T> | undefined
16
+
17
+ /**
18
+ * Sentry beforeSend hook to prevent sending events for ignored exceptions,
19
+ * namely numerous eager watermelon queries that fail after IndexedDB failure
20
+ */
21
+ export const floodPreventionSentryBeforeSend: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSend']>> = (_event, hint) => {
22
+ if (hint?.originalException && SyncTelemetry.getInstance()?.shouldIgnoreException(hint.originalException)) {
23
+ return null
24
+ }
25
+
26
+ return undefined
27
+ }
@@ -15,6 +15,15 @@ export type Span = InjectedSentry.Span
15
15
 
16
16
  export const SYNC_TELEMETRY_TRACE_PREFIX = 'sync:'
17
17
 
18
+ export enum SeverityLevel {
19
+ DEBUG = 0,
20
+ INFO = 1,
21
+ LOG = 2,
22
+ WARNING = 3,
23
+ ERROR = 4,
24
+ FATAL = 5
25
+ }
26
+
18
27
  export class SyncTelemetry {
19
28
  private static instance: SyncTelemetry | null = null
20
29
 
@@ -33,13 +42,20 @@ export class SyncTelemetry {
33
42
 
34
43
  private userId: string
35
44
  private Sentry: SentryLike;
45
+ private level: SeverityLevel
46
+ private pretty: boolean
47
+
48
+ private ignorePatterns: (string | RegExp)[] = []
36
49
 
37
50
  // allows us to know if Sentry shouldn't double-capture a dev-prettified console.error log
38
51
  private _ignoreConsole = false
39
52
 
40
- constructor(userId: string, { Sentry }: { Sentry: SentryLike }) {
53
+ constructor(userId: string, { Sentry, level, pretty }: { Sentry: SentryLike, level?: keyof typeof SeverityLevel, pretty?: boolean }) {
41
54
  this.userId = userId
42
55
  this.Sentry = Sentry
56
+ this.level = typeof level !== 'undefined' && level in SeverityLevel ? SeverityLevel[level] : SeverityLevel.LOG
57
+ this.pretty = typeof pretty !== 'undefined' ? pretty : true
58
+
43
59
  watermelonLogger.log = (...messages: any[]) => this.log('[Watermelon]', ...messages);
44
60
  watermelonLogger.warn = (...messages: any[]) => this.warn('[Watermelon]', ...messages);
45
61
  watermelonLogger.error = (...messages: any[]) => this.error('[Watermelon]', ...messages);
@@ -67,8 +83,10 @@ export class SyncTelemetry {
67
83
  })
68
84
  }
69
85
 
70
- capture(err: SyncError) {
71
- err.markReported()
86
+ capture(err: Error, context = {}) {
87
+ const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
88
+
89
+ wrapped.markReported()
72
90
  this.Sentry.captureException(err, err instanceof SyncUnexpectedError ? {
73
91
  mechanism: {
74
92
  handled: false
@@ -85,33 +103,59 @@ export class SyncTelemetry {
85
103
  return this._ignoreConsole
86
104
  }
87
105
 
106
+ /**
107
+ * Ignore messages/errors in the future that match provided patterns
108
+ */
109
+ ignoreLike(...patterns: (RegExp | string)[]) {
110
+ this.ignorePatterns.push(...patterns)
111
+ }
112
+
113
+ shouldIgnoreRejection(reason: any) {
114
+ const message = reason instanceof Error ? `${reason.name}: ${reason.message}` : reason
115
+ return this.shouldIgnoreMessage(message)
116
+ }
117
+
118
+ shouldIgnoreException(exception: unknown) {
119
+ if (exception instanceof Error) {
120
+ return this.shouldIgnoreMessage(exception.message)
121
+ }
122
+
123
+ return false
124
+ }
125
+
126
+ shouldIgnoreMessages(messages: any[]) {
127
+ return messages.some(message => {
128
+ return this.shouldIgnoreMessage(message)
129
+ })
130
+ }
131
+
88
132
  debug(...messages: any[]) {
89
- console.debug(...this.formattedConsoleMessages(...messages));
133
+ this.level <= SeverityLevel.DEBUG && !this.shouldIgnoreMessages(messages) && console.debug(...this.formattedConsoleMessages(...messages));
90
134
  this.recordBreadcrumb('debug', ...messages)
91
135
  }
92
136
 
93
137
  info(...messages: any[]) {
94
- console.info(...this.formattedConsoleMessages(...messages));
138
+ this.level <= SeverityLevel.INFO && !this.shouldIgnoreMessages(messages) && console.info(...this.formattedConsoleMessages(...messages));
95
139
  this.recordBreadcrumb('info', ...messages)
96
140
  }
97
141
 
98
142
  log(...messages: any[]) {
99
- console.log(...this.formattedConsoleMessages(...messages));
143
+ this.level <= SeverityLevel.LOG && !this.shouldIgnoreMessages(messages) && console.log(...this.formattedConsoleMessages(...messages));
100
144
  this.recordBreadcrumb('log', ...messages)
101
145
  }
102
146
 
103
147
  warn(...messages: any[]) {
104
- console.warn(...this.formattedConsoleMessages(...messages));
148
+ this.level <= SeverityLevel.WARNING && !this.shouldIgnoreMessages(messages) && console.warn(...this.formattedConsoleMessages(...messages));
105
149
  this.recordBreadcrumb('warning', ...messages)
106
150
  }
107
151
 
108
152
  error(...messages: any[]) {
109
- console.error(...this.formattedConsoleMessages(...messages));
153
+ this.level <= SeverityLevel.ERROR && !this.shouldIgnoreMessages(messages) && console.error(...this.formattedConsoleMessages(...messages));
110
154
  this.recordBreadcrumb('error', ...messages)
111
155
  }
112
156
 
113
157
  fatal(...messages: any[]) {
114
- console.error(...this.formattedConsoleMessages(...messages));
158
+ this.level <= SeverityLevel.FATAL && !this.shouldIgnoreMessages(messages) && console.error(...this.formattedConsoleMessages(...messages));
115
159
  this.recordBreadcrumb('fatal', ...messages)
116
160
  }
117
161
 
@@ -124,6 +168,10 @@ export class SyncTelemetry {
124
168
  }
125
169
 
126
170
  private formattedConsoleMessages(...messages: any[]) {
171
+ if (!this.pretty) {
172
+ return messages
173
+ }
174
+
127
175
  const date = new Date();
128
176
  return [...this.consolePrefix(date), ...messages, ...this.consoleSuffix(date)];
129
177
  }
@@ -136,4 +184,18 @@ export class SyncTelemetry {
136
184
  private consoleSuffix(date: Date) {
137
185
  return [` [${date.toLocaleTimeString()}, ${date.getTime()}]`];
138
186
  }
187
+
188
+ private shouldIgnoreMessage(message: any) {
189
+ if (message instanceof Error) message = message.message
190
+ if (typeof message !== 'string') return false
191
+
192
+ return this.ignorePatterns.some(pattern => {
193
+ if (typeof pattern === 'string') {
194
+ return message.indexOf(pattern) !== -1
195
+ } else if (pattern instanceof RegExp) {
196
+ return pattern.test(message)
197
+ }
198
+ return false
199
+ })
200
+ }
139
201
  }
@@ -38,16 +38,12 @@ export const syncSentryBeforeSendTransaction: ReturnsUndefined<NonNullable<Sentr
38
38
 
39
39
  // sentry doesn't bother to expose your chosen environment in tracesSampler
40
40
  // so we have to make consumers pass in our greedy option
41
- export const createSyncSentryTracesSampler = (greedy = false) => {
41
+ export const createSyncSentryTracesSampler = () => {
42
42
  const sampler: ReturnsUndefined<NonNullable<SentryBrowserOptions['tracesSampler']>> = (context) => {
43
43
  if (!context.name.startsWith(SYNC_TELEMETRY_TRACE_PREFIX)) {
44
44
  return undefined
45
45
  }
46
46
 
47
- if (greedy) {
48
- return true
49
- }
50
-
51
47
  const { parentSampled, attributes } = context
52
48
 
53
49
  if (parentSampled) {
@@ -58,7 +54,7 @@ export const createSyncSentryTracesSampler = (greedy = false) => {
58
54
  return userBucketedSampler(attributes.userId as string | number, 0.1)
59
55
  }
60
56
 
61
- return false
57
+ return undefined
62
58
  }
63
59
 
64
60
  return sampler
@@ -5,13 +5,6 @@ export interface BrandMethodLevels {
5
5
  singeo: string
6
6
  }
7
7
 
8
- export interface BrandTotalXp {
9
- drumeo: string
10
- pianote: string
11
- guitareo: string
12
- singeo: string
13
- }
14
-
15
8
  export interface BrandTimePracticed {
16
9
  drumeo: number
17
10
  pianote: number