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.
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/contentMetaData.js +0 -5
- package/src/contentTypeConfig.js +11 -73
- package/src/index.d.ts +0 -20
- package/src/index.js +0 -20
- package/src/services/content.js +1 -1
- package/src/services/contentAggregator.js +1 -4
- package/src/services/railcontent.js +0 -163
- package/src/services/sanity.js +258 -7
- package/src/services/sync/adapters/factory.ts +6 -6
- package/src/services/sync/adapters/lokijs.ts +174 -1
- package/src/services/sync/context/providers/durability.ts +1 -0
- package/src/services/sync/database/factory.ts +12 -5
- package/src/services/sync/effects/index.ts +6 -0
- package/src/services/sync/effects/logout-warning.ts +47 -0
- package/src/services/sync/errors/boundary.ts +4 -6
- package/src/services/sync/errors/index.ts +16 -0
- package/src/services/sync/fetch.ts +5 -5
- package/src/services/sync/manager.ts +80 -40
- package/src/services/sync/repositories/base.ts +1 -8
- package/src/services/sync/retry.ts +4 -4
- package/src/services/sync/store/index.ts +34 -31
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +10 -8
- package/src/services/sync/telemetry/flood-prevention.ts +27 -0
- package/src/services/sync/telemetry/index.ts +71 -9
- package/src/services/sync/telemetry/sampling.ts +2 -6
- package/src/services/user/types.d.ts +0 -7
- package/test/sync/adapter.ts +2 -34
- package/test/sync/initialize-sync-manager.js +8 -25
- package/.claude/settings.local.json +0 -9
- 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 {
|
|
12
|
+
import { SyncEffect } from './effects'
|
|
12
13
|
import { SyncTelemetry } from './telemetry/index'
|
|
13
|
-
import
|
|
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
|
-
|
|
28
|
+
|
|
29
|
+
return (force = false) => {
|
|
28
30
|
SyncManager.instance = null
|
|
29
|
-
|
|
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: {
|
|
52
|
-
private
|
|
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.
|
|
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.
|
|
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
|
|
79
|
-
return new SyncStore
|
|
88
|
+
createStore(config: SyncStoreConfig, database: Database) {
|
|
89
|
+
return new SyncStore(
|
|
80
90
|
config,
|
|
81
91
|
this.context,
|
|
82
|
-
|
|
92
|
+
database,
|
|
83
93
|
this.retry,
|
|
84
94
|
this.runScope,
|
|
85
95
|
this.telemetry
|
|
86
96
|
)
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
|
|
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,
|
|
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
|
-
|
|
109
|
-
this.strategyMap.push({
|
|
114
|
+
registerStrategies(models: ModelClass[], strategies: SyncStrategy[]) {
|
|
115
|
+
this.strategyMap.push({ models, strategies })
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
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(({
|
|
136
|
+
this.strategyMap.forEach(({ models, strategies }) => {
|
|
124
137
|
strategies.forEach((strategy) => {
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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:
|
|
71
|
-
err
|
|
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 = (
|
|
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
|
|
57
|
+
return undefined
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
return sampler
|