musora-content-services 2.89.0 → 2.92.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 (139) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Forums.html +2 -2
  4. package/docs/Gamification.html +2 -2
  5. package/docs/TestUser.html +2 -2
  6. package/docs/UserManagementSystem.html +2 -2
  7. package/docs/api_types.js.html +2 -2
  8. package/docs/config.js.html +2 -2
  9. package/docs/content-org_content-org.js.html +2 -2
  10. package/docs/content-org_guided-courses.ts.html +2 -2
  11. package/docs/content-org_learning-paths.ts.html +52 -40
  12. package/docs/content-org_playlists-types.js.html +2 -2
  13. package/docs/content-org_playlists.js.html +2 -2
  14. package/docs/content.js.html +2 -2
  15. package/docs/content_artist.ts.html +2 -2
  16. package/docs/content_genre.ts.html +2 -2
  17. package/docs/content_instructor.ts.html +2 -2
  18. package/docs/forums_categories.ts.html +2 -2
  19. package/docs/forums_forums.ts.html +2 -2
  20. package/docs/forums_posts.ts.html +2 -2
  21. package/docs/forums_threads.ts.html +2 -2
  22. package/docs/gamification_awards.ts.html +2 -2
  23. package/docs/gamification_gamification.js.html +2 -2
  24. package/docs/global.html +2 -2
  25. package/docs/index.html +2 -2
  26. package/docs/liveTesting.ts.html +2 -2
  27. package/docs/module-Accounts.html +2 -2
  28. package/docs/module-Artist.html +2 -2
  29. package/docs/module-Awards.html +2 -2
  30. package/docs/module-Config.html +2 -2
  31. package/docs/module-Content-Services-V2.html +2 -2
  32. package/docs/module-Forums.html +2 -2
  33. package/docs/module-Genre.html +2 -2
  34. package/docs/module-GuidedCourses.html +2 -2
  35. package/docs/module-Instructor.html +2 -2
  36. package/docs/module-Interests.html +2 -2
  37. package/docs/module-LearningPaths.html +269 -143
  38. package/docs/module-Onboarding.html +3 -3
  39. package/docs/module-Payments.html +2 -2
  40. package/docs/module-Permissions.html +2 -2
  41. package/docs/module-Playlists.html +2 -2
  42. package/docs/module-ProgressRow.html +2 -2
  43. package/docs/module-Railcontent-Services.html +34 -893
  44. package/docs/module-Sanity-Services.html +2 -2
  45. package/docs/module-Sessions.html +2 -2
  46. package/docs/module-UserActivity.html +70 -116
  47. package/docs/module-UserChat.html +2 -2
  48. package/docs/module-UserManagement.html +2 -2
  49. package/docs/module-UserMemberships.html +2 -2
  50. package/docs/module-UserNotifications.html +2 -2
  51. package/docs/module-UserProfile.html +2 -2
  52. package/docs/progress-row_method-card.js.html +3 -2
  53. package/docs/railcontent.js.html +14 -137
  54. package/docs/sanity.js.html +2 -2
  55. package/docs/userActivity.js.html +85 -150
  56. package/docs/user_account.ts.html +2 -2
  57. package/docs/user_chat.js.html +2 -2
  58. package/docs/user_interests.js.html +2 -2
  59. package/docs/user_management.js.html +2 -2
  60. package/docs/user_memberships.ts.html +2 -2
  61. package/docs/user_notifications.js.html +2 -2
  62. package/docs/user_onboarding.ts.html +10 -6
  63. package/docs/user_payments.ts.html +2 -2
  64. package/docs/user_permissions.js.html +2 -2
  65. package/docs/user_profile.js.html +2 -2
  66. package/docs/user_sessions.js.html +2 -2
  67. package/docs/user_types.js.html +2 -2
  68. package/docs/user_user-management-system.js.html +2 -2
  69. package/package.json +11 -3
  70. package/src/contentTypeConfig.js +6 -0
  71. package/src/index.d.ts +7 -31
  72. package/src/index.js +10 -34
  73. package/src/services/content-org/learning-paths.ts +31 -0
  74. package/src/services/contentAggregator.js +2 -2
  75. package/src/services/contentLikes.js +6 -39
  76. package/src/services/contentProgress.js +181 -479
  77. package/src/services/dataContext.js +0 -2
  78. package/src/services/progress-row/method-card.js +1 -0
  79. package/src/services/railcontent.js +12 -135
  80. package/src/services/sentry/.indexignore +0 -0
  81. package/src/services/sentry/index.ts +23 -0
  82. package/src/services/sync/.indexignore +0 -0
  83. package/src/services/sync/adapters/factory.ts +26 -0
  84. package/src/services/sync/adapters/lokijs.ts +1 -0
  85. package/src/services/sync/adapters/sqlite.ts +1 -0
  86. package/src/services/sync/concurrency-safety.ts +4 -0
  87. package/src/services/sync/context/index.ts +43 -0
  88. package/src/services/sync/context/providers/base.ts +4 -0
  89. package/src/services/sync/context/providers/connectivity.ts +14 -0
  90. package/src/services/sync/context/providers/durability.ts +5 -0
  91. package/src/services/sync/context/providers/index.ts +5 -0
  92. package/src/services/sync/context/providers/session.ts +8 -0
  93. package/src/services/sync/context/providers/tabs.ts +18 -0
  94. package/src/services/sync/context/providers/visibility.ts +14 -0
  95. package/src/services/sync/database/factory.ts +10 -0
  96. package/src/services/sync/errors/boundary.ts +45 -0
  97. package/src/services/sync/errors/index.ts +49 -0
  98. package/src/services/sync/fetch.ts +310 -0
  99. package/src/services/sync/index.ts +80 -0
  100. package/src/services/sync/manager.ts +139 -0
  101. package/src/services/sync/models/Base.ts +47 -0
  102. package/src/services/sync/models/ContentLike.ts +16 -0
  103. package/src/services/sync/models/ContentProgress.ts +69 -0
  104. package/src/services/sync/models/Practice.ts +72 -0
  105. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  106. package/src/services/sync/models/index.ts +4 -0
  107. package/src/services/sync/repositories/base.ts +247 -0
  108. package/src/services/sync/repositories/content-likes.ts +26 -0
  109. package/src/services/sync/repositories/content-progress.ts +160 -0
  110. package/src/services/sync/repositories/index.ts +4 -0
  111. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  112. package/src/services/sync/repositories/practices.ts +52 -0
  113. package/src/services/sync/repository-proxy.ts +48 -0
  114. package/src/services/sync/resolver.ts +84 -0
  115. package/src/services/sync/retry.ts +88 -0
  116. package/src/services/sync/run-scope.ts +30 -0
  117. package/src/services/sync/schema/index.ts +66 -0
  118. package/src/services/sync/serializers/index.ts +2 -0
  119. package/src/services/sync/serializers/model.ts +32 -0
  120. package/src/services/sync/serializers/raw.ts +21 -0
  121. package/src/services/sync/store/index.ts +779 -0
  122. package/src/services/sync/store/push-coalescer.ts +57 -0
  123. package/src/services/sync/store-configs.ts +41 -0
  124. package/src/services/sync/strategies/base.ts +21 -0
  125. package/src/services/sync/strategies/index.ts +12 -0
  126. package/src/services/sync/strategies/initial.ts +11 -0
  127. package/src/services/sync/strategies/polling.ts +54 -0
  128. package/src/services/sync/telemetry/index.ts +140 -0
  129. package/src/services/sync/telemetry/sampling.ts +91 -0
  130. package/src/services/sync/utils/event-emitter.ts +24 -0
  131. package/src/services/sync/utils/index.ts +1 -0
  132. package/src/services/sync/utils/throttle.ts +93 -0
  133. package/src/services/sync/utils/timers.ts +9 -0
  134. package/src/services/userActivity.js +83 -148
  135. package/test/contentProgress.test.js +6 -39
  136. package/test/live/contentProgressLive.test.js +2 -31
  137. package/tools/generate-index.cjs +10 -4
  138. package/.claude/settings.local.json +0 -8
  139. package/babel.config.cjs +0 -3
@@ -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
+ }