musora-content-services 2.107.8 → 2.110.3
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 +32 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/contentMetaData.js +0 -5
- package/src/contentTypeConfig.js +13 -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/contentProgress.js +13 -8
- 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/models/ContentProgress.ts +6 -0
- package/src/services/sync/repositories/base.ts +1 -8
- package/src/services/sync/repositories/content-progress.ts +8 -2
- package/src/services/sync/retry.ts +4 -4
- package/src/services/sync/schema/index.ts +1 -0
- 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
|
@@ -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
|
package/test/sync/adapter.ts
CHANGED
|
@@ -1,41 +1,9 @@
|
|
|
1
1
|
import adapterFactory from '../../src/services/sync/adapters/factory'
|
|
2
2
|
import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
|
|
3
|
-
import EventEmitter from '../../src/services/sync/utils/event-emitter'
|
|
4
3
|
|
|
5
|
-
export default function syncStoreAdapter(userId: string
|
|
4
|
+
export default function syncStoreAdapter(userId: string) {
|
|
6
5
|
return adapterFactory(LokiJSAdapter, `user:${userId}`, {
|
|
7
6
|
useWebWorker: false,
|
|
8
|
-
useIncrementalIndexedDB: true
|
|
9
|
-
extraLokiOptions: {
|
|
10
|
-
autosave: true,
|
|
11
|
-
autosaveInterval: 300, // increase for better performance at cost of potential data loss on tab close/crash
|
|
12
|
-
},
|
|
13
|
-
onQuotaExceededError: () => {
|
|
14
|
-
// Browser ran out of disk space or possibly in incognito mode
|
|
15
|
-
// called ONLY at startup
|
|
16
|
-
// ideal place to trigger banner (?) to user when also offline?
|
|
17
|
-
// (so that the non-customizable browser default onbeforeunload confirmation (in offline-unload-warning.ts) has context and makes sense)
|
|
18
|
-
bus.emit('quotaExceededError')
|
|
19
|
-
},
|
|
20
|
-
onSetUpError: () => {
|
|
21
|
-
// TODO - Database failed to load -- offer the user to reload the app or log out
|
|
22
|
-
},
|
|
23
|
-
extraIncrementalIDBOptions: {
|
|
24
|
-
lazyCollections: ['content_like'],
|
|
25
|
-
onDidOverwrite: () => {
|
|
26
|
-
// Called when this adapter is forced to overwrite contents of IndexedDB.
|
|
27
|
-
// This happens if there's another open tab of the same app that's making changes.
|
|
28
|
-
// this scenario is handled-ish in `idb-clobber-avoidance`
|
|
29
|
-
},
|
|
30
|
-
onversionchange: () => {
|
|
31
|
-
// no-op
|
|
32
|
-
// indexeddb was deleted in another browser tab (user logged out), so we must make sure we delete
|
|
33
|
-
// in-memory db in this tab as well,
|
|
34
|
-
// but we rely on sync manager setup/teardown to `unsafeResetDatabase` and redirect for this,
|
|
35
|
-
// though reloading the page might be useful as well
|
|
36
|
-
},
|
|
37
|
-
},
|
|
7
|
+
useIncrementalIndexedDB: true
|
|
38
8
|
})
|
|
39
9
|
}
|
|
40
|
-
|
|
41
|
-
export class SyncAdapterEventBus extends EventEmitter<{ quotaExceededError: [] }> {}
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
import { SyncManager, SyncContext } from '../../src/services/sync/index'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
BaseConnectivityProvider,
|
|
5
|
-
BaseDurabilityProvider,
|
|
6
|
-
BaseTabsProvider,
|
|
7
|
-
BaseVisibilityProvider,
|
|
8
|
-
} from '../../src/services/sync/context/providers/'
|
|
9
|
-
import adapterFactory from '../../src/services/sync/adapters/factory'
|
|
10
|
-
import LokiJSAdapter from '../../src/services/sync/adapters/lokijs'
|
|
11
|
-
import EventEmitter from '../../src/services/sync/utils/event-emitter'
|
|
12
|
-
import { InitialStrategy, PollingStrategy } from '../../src/services/sync/strategies/index'
|
|
13
|
-
import { SyncTelemetry, SentryLike } from '../../src/services/sync/telemetry/index'
|
|
2
|
+
import { InitialStrategy } from '../../src/services/sync/strategies/index'
|
|
3
|
+
import { SyncTelemetry, SeverityLevel, SentryLike } from '../../src/services/sync/telemetry/index'
|
|
14
4
|
import {
|
|
15
5
|
ContentLike,
|
|
16
6
|
ContentProgress,
|
|
@@ -19,7 +9,7 @@ import {
|
|
|
19
9
|
} from '../../src/services/sync/models/index'
|
|
20
10
|
import syncDatabaseFactory from '../../src/services/sync/database/factory'
|
|
21
11
|
|
|
22
|
-
import syncAdapter
|
|
12
|
+
import syncAdapter from './adapter'
|
|
23
13
|
|
|
24
14
|
export function initializeSyncManager(userId) {
|
|
25
15
|
if (SyncManager.getInstanceOrNull()) {
|
|
@@ -45,10 +35,9 @@ export function initializeSyncManager(userId) {
|
|
|
45
35
|
},
|
|
46
36
|
}
|
|
47
37
|
|
|
48
|
-
SyncTelemetry.setInstance(new SyncTelemetry(userId, { Sentry: dummySentry }))
|
|
38
|
+
SyncTelemetry.setInstance(new SyncTelemetry(userId, { Sentry: dummySentry, level: SeverityLevel.WARNING, pretty: false }))
|
|
49
39
|
|
|
50
|
-
const
|
|
51
|
-
const adapter = syncAdapter(userId, adapterBus)
|
|
40
|
+
const adapter = syncAdapter(userId)
|
|
52
41
|
const db = syncDatabaseFactory(adapter)
|
|
53
42
|
|
|
54
43
|
const context = new SyncContext({
|
|
@@ -88,16 +77,10 @@ export function initializeSyncManager(userId) {
|
|
|
88
77
|
const manager = new SyncManager(context, db)
|
|
89
78
|
|
|
90
79
|
const initialStrategy = manager.createStrategy(InitialStrategy)
|
|
91
|
-
const aggressivePollingStrategy = manager.createStrategy(PollingStrategy, 3600_000)
|
|
92
|
-
|
|
93
|
-
manager.syncStoresWithStrategies(
|
|
94
|
-
manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
|
|
95
|
-
[initialStrategy, aggressivePollingStrategy]
|
|
96
|
-
)
|
|
97
80
|
|
|
98
|
-
manager.
|
|
99
|
-
|
|
100
|
-
[]
|
|
81
|
+
manager.registerStrategies(
|
|
82
|
+
[ContentLike, ContentProgress, Practice, PracticeDayNote],
|
|
83
|
+
[initialStrategy]
|
|
101
84
|
)
|
|
102
85
|
|
|
103
86
|
SyncManager.assignAndSetupInstance(manager)
|