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.
Files changed (38) hide show
  1. package/CHANGELOG.md +32 -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 +13 -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/contentProgress.js +13 -8
  11. package/src/services/railcontent.js +0 -163
  12. package/src/services/sanity.js +258 -7
  13. package/src/services/sync/adapters/factory.ts +6 -6
  14. package/src/services/sync/adapters/lokijs.ts +174 -1
  15. package/src/services/sync/context/providers/durability.ts +1 -0
  16. package/src/services/sync/database/factory.ts +12 -5
  17. package/src/services/sync/effects/index.ts +6 -0
  18. package/src/services/sync/effects/logout-warning.ts +47 -0
  19. package/src/services/sync/errors/boundary.ts +4 -6
  20. package/src/services/sync/errors/index.ts +16 -0
  21. package/src/services/sync/fetch.ts +5 -5
  22. package/src/services/sync/manager.ts +80 -40
  23. package/src/services/sync/models/ContentProgress.ts +6 -0
  24. package/src/services/sync/repositories/base.ts +1 -8
  25. package/src/services/sync/repositories/content-progress.ts +8 -2
  26. package/src/services/sync/retry.ts +4 -4
  27. package/src/services/sync/schema/index.ts +1 -0
  28. package/src/services/sync/store/index.ts +34 -31
  29. package/src/services/sync/store/push-coalescer.ts +3 -3
  30. package/src/services/sync/store-configs.ts +10 -8
  31. package/src/services/sync/telemetry/flood-prevention.ts +27 -0
  32. package/src/services/sync/telemetry/index.ts +71 -9
  33. package/src/services/sync/telemetry/sampling.ts +2 -6
  34. package/src/services/user/types.d.ts +0 -7
  35. package/test/sync/adapter.ts +2 -34
  36. package/test/sync/initialize-sync-manager.js +8 -25
  37. package/.claude/settings.local.json +0 -9
  38. 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
- 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
@@ -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, bus: SyncAdapterEventBus) {
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
- BaseSessionProvider,
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, { SyncAdapterEventBus } from './adapter'
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 adapterBus = new SyncAdapterEventBus()
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.protectStores(
99
- manager.storesForModels([ContentLike, ContentProgress, Practice, PracticeDayNote]),
100
- []
81
+ manager.registerStrategies(
82
+ [ContentLike, ContentProgress, Practice, PracticeDayNote],
83
+ [initialStrategy]
101
84
  )
102
85
 
103
86
  SyncManager.assignAndSetupInstance(manager)
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(npm run lint:*)"
6
- ],
7
- "deny": []
8
- }
9
- }
@@ -1,4 +0,0 @@
1
- import type SyncContext from "./context"
2
- import type SyncStore from "./store"
3
-
4
- export type SyncConcurrencySafetyMechanism = (context: SyncContext, stores: SyncStore[]) => () => void