musora-content-services 2.90.0 → 2.92.6

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 (177) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +11 -3
  3. package/src/index.d.ts +9 -31
  4. package/src/index.js +12 -34
  5. package/src/services/content-org/learning-paths.ts +33 -3
  6. package/src/services/contentAggregator.js +2 -2
  7. package/src/services/contentLikes.js +6 -39
  8. package/src/services/contentProgress.js +181 -479
  9. package/src/services/dataContext.js +0 -2
  10. package/src/services/progress-row/method-card.js +2 -1
  11. package/src/services/railcontent.js +12 -135
  12. package/src/services/sentry/.indexignore +0 -0
  13. package/src/services/sentry/index.ts +23 -0
  14. package/src/services/sync/.indexignore +0 -0
  15. package/src/services/sync/adapters/factory.ts +26 -0
  16. package/src/services/sync/adapters/lokijs.ts +1 -0
  17. package/src/services/sync/adapters/sqlite.ts +1 -0
  18. package/src/services/sync/concurrency-safety.ts +4 -0
  19. package/src/services/sync/context/index.ts +43 -0
  20. package/src/services/sync/context/providers/base.ts +4 -0
  21. package/src/services/sync/context/providers/connectivity.ts +14 -0
  22. package/src/services/sync/context/providers/durability.ts +5 -0
  23. package/src/services/sync/context/providers/index.ts +5 -0
  24. package/src/services/sync/context/providers/session.ts +8 -0
  25. package/src/services/sync/context/providers/tabs.ts +18 -0
  26. package/src/services/sync/context/providers/visibility.ts +14 -0
  27. package/src/services/sync/database/factory.ts +10 -0
  28. package/src/services/sync/errors/boundary.ts +45 -0
  29. package/src/services/sync/errors/index.ts +49 -0
  30. package/src/services/sync/fetch.ts +313 -0
  31. package/src/services/sync/index.ts +80 -0
  32. package/src/services/sync/manager.ts +139 -0
  33. package/src/services/sync/models/Base.ts +47 -0
  34. package/src/services/sync/models/ContentLike.ts +16 -0
  35. package/src/services/sync/models/ContentProgress.ts +69 -0
  36. package/src/services/sync/models/Practice.ts +72 -0
  37. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  38. package/src/services/sync/models/index.ts +4 -0
  39. package/src/services/sync/repositories/base.ts +247 -0
  40. package/src/services/sync/repositories/content-likes.ts +26 -0
  41. package/src/services/sync/repositories/content-progress.ts +160 -0
  42. package/src/services/sync/repositories/index.ts +4 -0
  43. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  44. package/src/services/sync/repositories/practices.ts +52 -0
  45. package/src/services/sync/repository-proxy.ts +48 -0
  46. package/src/services/sync/resolver.ts +84 -0
  47. package/src/services/sync/retry.ts +88 -0
  48. package/src/services/sync/run-scope.ts +30 -0
  49. package/src/services/sync/schema/index.ts +66 -0
  50. package/src/services/sync/serializers/index.ts +2 -0
  51. package/src/services/sync/serializers/model.ts +32 -0
  52. package/src/services/sync/serializers/raw.ts +21 -0
  53. package/src/services/sync/store/index.ts +779 -0
  54. package/src/services/sync/store/push-coalescer.ts +57 -0
  55. package/src/services/sync/store-configs.ts +41 -0
  56. package/src/services/sync/strategies/base.ts +21 -0
  57. package/src/services/sync/strategies/index.ts +12 -0
  58. package/src/services/sync/strategies/initial.ts +11 -0
  59. package/src/services/sync/strategies/polling.ts +54 -0
  60. package/src/services/sync/telemetry/index.ts +140 -0
  61. package/src/services/sync/telemetry/sampling.ts +91 -0
  62. package/src/services/sync/utils/event-emitter.ts +24 -0
  63. package/src/services/sync/utils/index.ts +1 -0
  64. package/src/services/sync/utils/throttle.ts +93 -0
  65. package/src/services/sync/utils/timers.ts +9 -0
  66. package/src/services/userActivity.js +83 -148
  67. package/test/contentProgress.test.js +6 -39
  68. package/test/live/contentProgressLive.test.js +2 -31
  69. package/tools/generate-index.cjs +10 -4
  70. package/babel.config.cjs +0 -3
  71. package/docs/Content.html +0 -269
  72. package/docs/ContentOrganization.html +0 -245
  73. package/docs/Forums.html +0 -269
  74. package/docs/Gamification.html +0 -245
  75. package/docs/TestUser.html +0 -260
  76. package/docs/UserManagementSystem.html +0 -317
  77. package/docs/api_types.js.html +0 -97
  78. package/docs/config.js.html +0 -140
  79. package/docs/content-org_content-org.js.html +0 -76
  80. package/docs/content-org_guided-courses.ts.html +0 -110
  81. package/docs/content-org_learning-paths.ts.html +0 -379
  82. package/docs/content-org_playlists-types.js.html +0 -128
  83. package/docs/content-org_playlists.js.html +0 -440
  84. package/docs/content.js.html +0 -603
  85. package/docs/content_artist.ts.html +0 -206
  86. package/docs/content_content.ts.html +0 -77
  87. package/docs/content_genre.ts.html +0 -209
  88. package/docs/content_instructor.ts.html +0 -206
  89. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  90. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  91. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  92. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  93. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  94. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  95. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  96. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  97. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  98. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -978
  99. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  100. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  101. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  102. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  103. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -1049
  104. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  105. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  106. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  107. package/docs/forums_categories.ts.html +0 -156
  108. package/docs/forums_discussions.js.html +0 -95
  109. package/docs/forums_forum.js.html +0 -95
  110. package/docs/forums_forums.ts.html +0 -160
  111. package/docs/forums_posts.ts.html +0 -284
  112. package/docs/forums_threads.ts.html +0 -284
  113. package/docs/gamification_awards.js.html +0 -165
  114. package/docs/gamification_awards.ts.html +0 -195
  115. package/docs/gamification_gamification.js.html +0 -76
  116. package/docs/gamification_types.js.html +0 -80
  117. package/docs/global.html +0 -6019
  118. package/docs/index.html +0 -167
  119. package/docs/liveTesting.ts.html +0 -103
  120. package/docs/module-Accounts.html +0 -2283
  121. package/docs/module-Artist.html +0 -993
  122. package/docs/module-Awards.html +0 -836
  123. package/docs/module-Categories.html +0 -711
  124. package/docs/module-Config.html +0 -431
  125. package/docs/module-Content-Services-V2.html +0 -2998
  126. package/docs/module-ForumCategories.html +0 -687
  127. package/docs/module-ForumDiscussions.html +0 -370
  128. package/docs/module-Forums.html +0 -16599
  129. package/docs/module-Genre.html +0 -981
  130. package/docs/module-GuidedCourses.html +0 -108
  131. package/docs/module-Instructor.html +0 -929
  132. package/docs/module-Interests.html +0 -1066
  133. package/docs/module-LearningPaths.html +0 -2298
  134. package/docs/module-Onboarding.html +0 -882
  135. package/docs/module-Payments.html +0 -392
  136. package/docs/module-Permissions.html +0 -406
  137. package/docs/module-Playlists.html +0 -3030
  138. package/docs/module-ProgressRow.html +0 -108
  139. package/docs/module-Railcontent-Services.html +0 -6735
  140. package/docs/module-Sanity-Services.html +0 -8244
  141. package/docs/module-Sessions.html +0 -575
  142. package/docs/module-Threads.html +0 -1119
  143. package/docs/module-UserActivity.html +0 -4580
  144. package/docs/module-UserChat.html +0 -410
  145. package/docs/module-UserManagement.html +0 -1932
  146. package/docs/module-UserMemberships.html +0 -829
  147. package/docs/module-UserNotifications.html +0 -2595
  148. package/docs/module-UserProfile.html +0 -370
  149. package/docs/progress-row_method-card.js.html +0 -183
  150. package/docs/railcontent.js.html +0 -847
  151. package/docs/sanity.js.html +0 -2322
  152. package/docs/scripts/collapse.js +0 -39
  153. package/docs/scripts/commonNav.js +0 -28
  154. package/docs/scripts/linenumber.js +0 -25
  155. package/docs/scripts/nav.js +0 -12
  156. package/docs/scripts/polyfill.js +0 -4
  157. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
  158. package/docs/scripts/prettify/lang-css.js +0 -2
  159. package/docs/scripts/prettify/prettify.js +0 -28
  160. package/docs/scripts/search.js +0 -99
  161. package/docs/styles/jsdoc.css +0 -776
  162. package/docs/styles/prettify.css +0 -80
  163. package/docs/userActivity.js.html +0 -1577
  164. package/docs/user_account.ts.html +0 -265
  165. package/docs/user_chat.js.html +0 -98
  166. package/docs/user_interests.js.html +0 -150
  167. package/docs/user_management.js.html +0 -258
  168. package/docs/user_memberships.js.html +0 -144
  169. package/docs/user_memberships.ts.html +0 -292
  170. package/docs/user_notifications.js.html +0 -374
  171. package/docs/user_onboarding.ts.html +0 -325
  172. package/docs/user_payments.ts.html +0 -146
  173. package/docs/user_permissions.js.html +0 -110
  174. package/docs/user_profile.js.html +0 -115
  175. package/docs/user_sessions.js.html +0 -170
  176. package/docs/user_types.js.html +0 -224
  177. package/docs/user_user-management-system.js.html +0 -79
@@ -0,0 +1,57 @@
1
+ import BaseModel from "../models/Base"
2
+ import { RecordId } from "@nozbe/watermelondb"
3
+ import { EpochMs } from ".."
4
+ import { SyncPushResponse } from "../fetch"
5
+
6
+ type PushIntent = {
7
+ promise: Promise<SyncPushResponse>
8
+ records: {
9
+ id: RecordId
10
+ updatedAt: EpochMs
11
+ }[]
12
+ }
13
+
14
+ export default class PushCoalescer {
15
+ private intents: PushIntent[]
16
+
17
+ constructor() {
18
+ this.intents = []
19
+ }
20
+
21
+ push(records: BaseModel[], pusher: (records: BaseModel[]) => Promise<SyncPushResponse>) {
22
+ const found = this.find(records)
23
+
24
+ if (found) {
25
+ return found.promise
26
+ }
27
+
28
+ return this.add(pusher(records), records)
29
+ }
30
+
31
+ private add(promise: Promise<SyncPushResponse>, records: BaseModel[]) {
32
+ const intent = {
33
+ promise,
34
+ records: records.map(record => ({
35
+ id: record.id,
36
+ updatedAt: record.updated_at
37
+ }))
38
+ }
39
+
40
+ const cleanup = () => this.intents.splice(this.intents.indexOf(intent), 1)
41
+ intent.promise.finally(cleanup)
42
+
43
+ this.intents.push(intent)
44
+
45
+ return intent.promise
46
+ }
47
+
48
+ private find(records: BaseModel[]) {
49
+ return this.intents.find(intent => {
50
+ return records.every(record => {
51
+ return intent.records.find(({ id, updatedAt }) => {
52
+ return id === record.id && updatedAt >= record.updated_at
53
+ })
54
+ })
55
+ })
56
+ }
57
+ }
@@ -0,0 +1,41 @@
1
+ import { SyncStoreConfig } from "./store"
2
+ import { ContentLike, ContentProgress, Practice, PracticeDayNote } from "./models"
3
+ import { handlePull, handlePush, makeFetchRequest } from "./fetch"
4
+
5
+ import type SyncStore from "./store"
6
+ import type BaseModel from "./models/Base"
7
+
8
+ export default function createStoresFromConfig(createStore: <TModel extends BaseModel>(config: SyncStoreConfig<TModel>) => SyncStore<TModel>) {
9
+ return [
10
+ createStore({
11
+ model: ContentLike,
12
+ pull: handlePull(makeFetchRequest('/api/content/v1/user/likes')),
13
+ push: handlePush(makeFetchRequest('/api/content/v1/user/likes', { method: 'POST' })),
14
+ }),
15
+
16
+ createStore({
17
+ model: ContentProgress,
18
+ comparator: (server, local) => {
19
+ if (server.record.progress_percent === 0 || local.progress_percent === 0) {
20
+ return server.meta.lifecycle.updated_at >= local.updated_at ? 'SERVER' : 'LOCAL'
21
+ } else {
22
+ return server.record.progress_percent >= local.progress_percent ? 'SERVER' : 'LOCAL'
23
+ }
24
+ },
25
+ pull: handlePull(makeFetchRequest('/content/user/progress')),
26
+ push: handlePush(makeFetchRequest('/content/user/progress', { method: 'POST' })),
27
+ }),
28
+
29
+ createStore({
30
+ model: Practice,
31
+ pull: handlePull(makeFetchRequest('/api/user/practices/v1')),
32
+ push: handlePush(makeFetchRequest('/api/user/practices/v1', { method: 'POST' })),
33
+ }),
34
+
35
+ createStore({
36
+ model: PracticeDayNote,
37
+ pull: handlePull(makeFetchRequest('/api/user/practices/v1/notes')),
38
+ push: handlePush(makeFetchRequest('/api/user/practices/v1/notes', { method: 'POST' })),
39
+ })
40
+ ] as unknown as SyncStore<BaseModel>[]
41
+ }
@@ -0,0 +1,21 @@
1
+ import { SyncStrategy } from "./index";
2
+ import SyncContext from "../context";
3
+ import SyncStore from "../store";
4
+
5
+ export default abstract class BaseSyncStrategy implements SyncStrategy {
6
+ protected registry: { callback: (reason: string) => void, store: SyncStore }[] = []
7
+ abstract start(): void
8
+ abstract stop(): void
9
+
10
+ protected context: SyncContext
11
+
12
+ constructor(context: SyncContext) {
13
+ this.context = context
14
+ this.registry = []
15
+ }
16
+
17
+ onTrigger(store: SyncStore, callback: (reason: string) => void): this {
18
+ this.registry.push({ callback, store })
19
+ return this
20
+ }
21
+ }
@@ -0,0 +1,12 @@
1
+ import SyncStore from "../store";
2
+
3
+ export interface SyncStrategy {
4
+ start(): void
5
+ stop(): void
6
+
7
+ onTrigger(store: SyncStore, callback: (reason: string) => void): this
8
+ }
9
+
10
+ export { default as BaseStrategy } from './base'
11
+ export { default as InitialStrategy } from './initial'
12
+ export { default as PollingStrategy } from './polling'
@@ -0,0 +1,11 @@
1
+ import { BaseStrategy } from '.'
2
+
3
+ export default class InitialStrategy extends BaseStrategy {
4
+ start() {
5
+ this.registry.forEach(({ callback }) => {
6
+ callback('initial')
7
+ })
8
+ }
9
+
10
+ stop() {}
11
+ }
@@ -0,0 +1,54 @@
1
+ import { DefaultTimers, Timers } from "../utils/timers";
2
+ import { BaseStrategy } from "./index";
3
+ import SyncContext from "../context";
4
+ import SyncStore from "../store";
5
+
6
+ export default class PollingStrategy extends BaseStrategy {
7
+ private active = false
8
+ private storeTimers = new Map<SyncStore, any>()
9
+
10
+ constructor(
11
+ context: SyncContext,
12
+ private intervalMs: number,
13
+ private readonly timers: Timers = DefaultTimers
14
+ ) {
15
+ super(context)
16
+ }
17
+
18
+ start() {
19
+ if (this.active) return
20
+ this.active = true
21
+
22
+ this.registry.forEach(({ store, callback }) => {
23
+ store.on('pullCompleted', () => {
24
+ this.resetTimer(store, callback)
25
+ })
26
+ this.startTimer(store, callback)
27
+ })
28
+ }
29
+
30
+ stop() {
31
+ this.active = false
32
+ this.storeTimers.forEach(timerId => this.timers.clearTimeout(timerId))
33
+ this.storeTimers.clear()
34
+ }
35
+
36
+ private startTimer(store: SyncStore, callback: (reason: string) => void) {
37
+ this.timers.clearTimeout(this.storeTimers.get(store))
38
+
39
+ const timerId = this.timers.setTimeout(() => {
40
+ if (this.context.visibility.getValue()) {
41
+ callback('polling')
42
+ }
43
+ this.startTimer(store, callback)
44
+ }, this.intervalMs)
45
+
46
+ this.storeTimers.set(store, timerId)
47
+ }
48
+
49
+ private resetTimer(store: SyncStore, callback: (reason: string) => void) {
50
+ if (this.active) {
51
+ this.startTimer(store, callback)
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,140 @@
1
+ import watermelonLogger from '@nozbe/watermelondb/utils/common/logger'
2
+ import { SyncError, SyncUnexpectedError } from '../errors'
3
+
4
+ import * as InjectedSentry from '@sentry/browser'
5
+ export type SentryBrowserOptions = NonNullable<Parameters<typeof InjectedSentry.init>[0]>;
6
+
7
+ export type SentryLike = {
8
+ captureException: typeof InjectedSentry.captureException
9
+ addBreadcrumb: typeof InjectedSentry.addBreadcrumb
10
+ startSpan: typeof InjectedSentry.startSpan
11
+ }
12
+
13
+ export type StartSpanOptions = Parameters<typeof InjectedSentry.startSpan>[0]
14
+ export type Span = InjectedSentry.Span
15
+
16
+ export const SYNC_TELEMETRY_TRACE_PREFIX = 'sync:'
17
+
18
+ export class SyncTelemetry {
19
+ private static instance: SyncTelemetry | null = null
20
+
21
+ public static setInstance(instance: SyncTelemetry): SyncTelemetry {
22
+ SyncTelemetry.instance = instance
23
+ return instance
24
+ }
25
+
26
+ public static getInstance(): SyncTelemetry | null {
27
+ return SyncTelemetry.instance
28
+ }
29
+
30
+ public static clearInstance(): void {
31
+ SyncTelemetry.instance = null
32
+ }
33
+
34
+ private userId: string
35
+ private Sentry: SentryLike;
36
+
37
+ // allows us to know if Sentry shouldn't double-capture a dev-prettified console.error log
38
+ private _ignoreConsole = false
39
+
40
+ constructor(userId: string, { Sentry }: { Sentry: SentryLike }) {
41
+ this.userId = userId
42
+ this.Sentry = Sentry
43
+ watermelonLogger.log = (...messages: any[]) => this.log('[Watermelon]', ...messages);
44
+ watermelonLogger.warn = (...messages: any[]) => this.warn('[Watermelon]', ...messages);
45
+ watermelonLogger.error = (...messages: any[]) => this.error('[Watermelon]', ...messages);
46
+ }
47
+
48
+ trace<T>(opts: StartSpanOptions, callback: (_span: Span) => T) {
49
+ const options = {
50
+ ...opts,
51
+ name: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.name}`,
52
+ op: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.op}`,
53
+ attributes: {
54
+ ...opts.attributes,
55
+ userId: this.userId
56
+ }
57
+ }
58
+ return this.Sentry.startSpan<T>(options, (span) => {
59
+ let desc = span['_spanId'].slice(0, 4)
60
+ desc += span['_parentSpanId'] ? ` (< ${span['_parentSpanId'].slice(0, 4)})` : ''
61
+
62
+ this.debug(`[trace:start] ${options.name} (${desc})`)
63
+ const result = callback(span)
64
+ Promise.resolve(result).finally(() => this.debug(`[trace:end] ${options.name} (${desc})`))
65
+
66
+ return result
67
+ })
68
+ }
69
+
70
+ capture(err: SyncError) {
71
+ err.markReported()
72
+ this.Sentry.captureException(err, err instanceof SyncUnexpectedError ? {
73
+ mechanism: {
74
+ handled: false
75
+ }
76
+ } : undefined)
77
+
78
+
79
+ this._ignoreConsole = true
80
+ this.error(err.message)
81
+ this._ignoreConsole = false
82
+ }
83
+
84
+ // allows us to know if Sentry shouldn't double-capture a dev-prettified console.error log
85
+ shouldIgnoreConsole() {
86
+ return this._ignoreConsole
87
+ }
88
+
89
+ debug(...messages: any[]) {
90
+ console.debug(...this.formattedConsoleMessages(...messages));
91
+ this.recordBreadcrumb('debug', ...messages)
92
+ }
93
+
94
+ info(...messages: any[]) {
95
+ console.info(...this.formattedConsoleMessages(...messages));
96
+ this.recordBreadcrumb('info', ...messages)
97
+ }
98
+
99
+ log(...messages: any[]) {
100
+ console.log(...this.formattedConsoleMessages(...messages));
101
+ this.recordBreadcrumb('log', ...messages)
102
+ }
103
+
104
+ warn(...messages: any[]) {
105
+ console.warn(...this.formattedConsoleMessages(...messages));
106
+ this.recordBreadcrumb('warning', ...messages)
107
+ }
108
+
109
+ error(...messages: any[]) {
110
+ console.error(...this.formattedConsoleMessages(...messages));
111
+ this.recordBreadcrumb('error', ...messages)
112
+ }
113
+
114
+ fatal(...messages: any[]) {
115
+ console.error(...this.formattedConsoleMessages(...messages));
116
+ this.recordBreadcrumb('fatal', ...messages)
117
+ }
118
+
119
+ private recordBreadcrumb(level: InjectedSentry.Breadcrumb['level'], ...messages: any[]) {
120
+ this.Sentry.addBreadcrumb({
121
+ message: messages.join(', '),
122
+ level,
123
+ category: 'sync',
124
+ })
125
+ }
126
+
127
+ private formattedConsoleMessages(...messages: any[]) {
128
+ const date = new Date();
129
+ return [...this.consolePrefix(date), ...messages, ...this.consoleSuffix(date)];
130
+ }
131
+
132
+ private consolePrefix(date: Date) {
133
+ const now = Math.round(date.getTime() / 1000).toString();
134
+ return [`📡 SYNC: (%c${now.slice(0, 5)}%c${now.slice(5, 10)})`, 'color: #ccc', 'font-weight: bold;'];
135
+ }
136
+
137
+ private consoleSuffix(date: Date) {
138
+ return [` [${date.toLocaleTimeString()}, ${date.getTime()}]`];
139
+ }
140
+ }
@@ -0,0 +1,91 @@
1
+ import { SyncTelemetry, SYNC_TELEMETRY_TRACE_PREFIX, type SentryBrowserOptions } from '.'
2
+ import { SyncError } from '../errors'
3
+
4
+ type ReturnsUndefined<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T> | undefined
5
+
6
+ export const syncSentryBeforeSend: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSend']>> = (event, hint) => {
7
+ if (event.logger === 'console' && SyncTelemetry.getInstance().shouldIgnoreConsole()) {
8
+ return null
9
+ }
10
+
11
+ if (hint?.originalException instanceof SyncError) {
12
+ if (hint.originalException.isReported()) {
13
+ return null
14
+ }
15
+
16
+ // populate event with error data Sentry would otherwise ignore
17
+ event.extra = {
18
+ ...event.extra,
19
+ details: hint.originalException.getDetails()
20
+ }
21
+
22
+ return event
23
+ }
24
+
25
+ return undefined
26
+ }
27
+
28
+ export const syncSentryBeforeSendTransaction: ReturnsUndefined<NonNullable<SentryBrowserOptions['beforeSendTransaction']>> = (event, hint) => {
29
+ if (event.contexts?.trace?.op?.startsWith(SYNC_TELEMETRY_TRACE_PREFIX)) {
30
+ // filter out noisy empty sync traces
31
+ if (event.contexts.trace.op === `${SYNC_TELEMETRY_TRACE_PREFIX}sync` && event.spans?.length === 0) {
32
+ return null
33
+ }
34
+ }
35
+
36
+ return undefined
37
+ }
38
+
39
+ // sentry doesn't bother to expose your chosen environment in tracesSampler
40
+ // so we have to make consumers pass in our greedy option
41
+ export const createSyncSentryTracesSampler = (greedy = false) => {
42
+ const sampler: ReturnsUndefined<NonNullable<SentryBrowserOptions['tracesSampler']>> = (context) => {
43
+ if (!context.name.startsWith(SYNC_TELEMETRY_TRACE_PREFIX)) {
44
+ return undefined
45
+ }
46
+
47
+ if (greedy) {
48
+ return true
49
+ }
50
+
51
+ const { parentSampled, attributes } = context
52
+
53
+ if (parentSampled) {
54
+ return true
55
+ }
56
+
57
+ if (attributes?.userId) {
58
+ return userBucketedSampler(attributes.userId as string | number, 0.1)
59
+ }
60
+
61
+ return false
62
+ }
63
+
64
+ return sampler
65
+ }
66
+
67
+ /**
68
+ * Returns a sampling decision (0 or 1) based on user ID and rotation period.
69
+ *
70
+ * @param userId - string or number uniquely identifying the user
71
+ * @param sampleRate - fraction of users to include (0..1)
72
+ * @param rotationPeriodMs - rotation period in milliseconds (e.g., 1 day, 1 week)
73
+ */
74
+ function userBucketedSampler(
75
+ userId: string | number,
76
+ sampleRate: number,
77
+ rotationPeriodMs: number = 7 * 24 * 60 * 60 * 1000 // default: 1 week
78
+ ): 0 | 1 {
79
+ const uid = typeof userId === 'string' ? hashString(userId) : userId;
80
+ const now = Date.now();
81
+ const bucket = (uid + Math.floor(now / rotationPeriodMs)) % 100;
82
+ return bucket < sampleRate * 100 ? 1 : 0;
83
+ }
84
+
85
+ function hashString(str: string): number {
86
+ let hash = 0;
87
+ for (let i = 0; i < str.length; i++) {
88
+ hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
89
+ }
90
+ return hash;
91
+ }
@@ -0,0 +1,24 @@
1
+ type EventMap = Record<string, any[]>;
2
+
3
+ export default class EventEmitter<Events extends EventMap> {
4
+ private events: {
5
+ [K in keyof Events]?: Array<(...args: Events[K]) => void>
6
+ } = {};
7
+
8
+ on<K extends keyof Events>(event: K, fn: (...args: Events[K]) => void): () => void {
9
+ (this.events[event] ||= []).push(fn);
10
+ return () => this.off(event, fn);
11
+ }
12
+
13
+ off<K extends keyof Events>(event: K, fn?: (...args: Events[K]) => void) {
14
+ if (!fn) {
15
+ delete this.events[event];
16
+ } else {
17
+ this.events[event] = this.events[event]?.filter(f => f !== fn) || [];
18
+ }
19
+ }
20
+
21
+ emit<K extends keyof Events>(event: K, ...args: Events[K]) {
22
+ this.events[event]?.forEach(fn => fn(...args));
23
+ }
24
+ }
@@ -0,0 +1 @@
1
+ export * from './throttle'
@@ -0,0 +1,93 @@
1
+ export interface ThrottleState<T> {
2
+ isWaiting: boolean
3
+ current: Promise<T> | null;
4
+ next: (() => Promise<T>) | null;
5
+ lastCallTime: number;
6
+ minIntervalMs: number;
7
+ }
8
+
9
+ export function createThrottleState<T>(minIntervalMs: number): ThrottleState<T> {
10
+ return {
11
+ isWaiting: false,
12
+ current: null,
13
+ next: null,
14
+ lastCallTime: 0,
15
+ minIntervalMs
16
+ };
17
+ }
18
+
19
+ export function dropThrottle<T>(
20
+ options: { state: ThrottleState<T>, deferOnce?: boolean },
21
+ fn: (..._args: any[]) => Promise<T>
22
+ ) {
23
+ return (...args: any[]) => {
24
+ const { state } = options
25
+
26
+ const wait = () => {
27
+ return new Promise<void>(resolve => {
28
+ state.isWaiting = true
29
+ const t = Date.now() - state.lastCallTime
30
+ if (t < state.minIntervalMs) {
31
+ setTimeout(resolve, state.minIntervalMs - t)
32
+ } else {
33
+ resolve()
34
+ }
35
+ }).finally(() => {
36
+ state.isWaiting = false
37
+ })
38
+ }
39
+
40
+ const run = (fn: (..._args: any[]) => Promise<T>, args: any[]) => {
41
+ state.lastCallTime = Date.now()
42
+ return fn(...args).finally(() => {
43
+ if (state.next) {
44
+ const n = state.next()
45
+ state.next = null
46
+ state.current = n
47
+ } else {
48
+ state.current = null
49
+ }
50
+ })
51
+ }
52
+
53
+ if (!state.current) {
54
+ state.current = wait().then(() => run(fn, args))
55
+ } else if (options.deferOnce && !state.isWaiting) {
56
+ state.next = () => run(fn, args)
57
+ }
58
+
59
+ return state.current
60
+ }
61
+ }
62
+
63
+ export function queueThrottle<T>(
64
+ options: { state: ThrottleState<T>, deferOnce?: boolean },
65
+ fn: (..._args: any[]) => Promise<T>
66
+ ) {
67
+ return (...args: any[]) => {
68
+ const { state } = options
69
+
70
+ const run = async () => {
71
+ const elapsed = Date.now() - state.lastCallTime
72
+ if (elapsed < state.minIntervalMs) {
73
+ await new Promise<void>(r => setTimeout(r, state.minIntervalMs - elapsed))
74
+ }
75
+
76
+ state.lastCallTime = Date.now()
77
+ return fn(...args).finally(() => {
78
+ const next = state.next
79
+ state.next = null
80
+ state.current = next ? next() : null
81
+ })
82
+ }
83
+
84
+ if (!state.current) {
85
+ state.current = run()
86
+ } else {
87
+ state.next = () => run()
88
+ }
89
+
90
+ return state.current
91
+ }
92
+
93
+ }
@@ -0,0 +1,9 @@
1
+ export interface Timers {
2
+ setTimeout(fn: () => void, ms: number): any
3
+ clearTimeout(id: any): void
4
+ }
5
+
6
+ export const DefaultTimers: Timers = {
7
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
8
+ clearTimeout: (id) => clearTimeout(id),
9
+ }