musora-content-services 2.90.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 (137) hide show
  1. package/CHANGELOG.md +24 -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/index.d.ts +7 -31
  71. package/src/index.js +10 -34
  72. package/src/services/content-org/learning-paths.ts +30 -0
  73. package/src/services/contentAggregator.js +2 -2
  74. package/src/services/contentLikes.js +6 -39
  75. package/src/services/contentProgress.js +181 -479
  76. package/src/services/dataContext.js +0 -2
  77. package/src/services/progress-row/method-card.js +1 -0
  78. package/src/services/railcontent.js +12 -135
  79. package/src/services/sentry/.indexignore +0 -0
  80. package/src/services/sentry/index.ts +23 -0
  81. package/src/services/sync/.indexignore +0 -0
  82. package/src/services/sync/adapters/factory.ts +26 -0
  83. package/src/services/sync/adapters/lokijs.ts +1 -0
  84. package/src/services/sync/adapters/sqlite.ts +1 -0
  85. package/src/services/sync/concurrency-safety.ts +4 -0
  86. package/src/services/sync/context/index.ts +43 -0
  87. package/src/services/sync/context/providers/base.ts +4 -0
  88. package/src/services/sync/context/providers/connectivity.ts +14 -0
  89. package/src/services/sync/context/providers/durability.ts +5 -0
  90. package/src/services/sync/context/providers/index.ts +5 -0
  91. package/src/services/sync/context/providers/session.ts +8 -0
  92. package/src/services/sync/context/providers/tabs.ts +18 -0
  93. package/src/services/sync/context/providers/visibility.ts +14 -0
  94. package/src/services/sync/database/factory.ts +10 -0
  95. package/src/services/sync/errors/boundary.ts +45 -0
  96. package/src/services/sync/errors/index.ts +49 -0
  97. package/src/services/sync/fetch.ts +310 -0
  98. package/src/services/sync/index.ts +80 -0
  99. package/src/services/sync/manager.ts +139 -0
  100. package/src/services/sync/models/Base.ts +47 -0
  101. package/src/services/sync/models/ContentLike.ts +16 -0
  102. package/src/services/sync/models/ContentProgress.ts +69 -0
  103. package/src/services/sync/models/Practice.ts +72 -0
  104. package/src/services/sync/models/PracticeDayNote.ts +23 -0
  105. package/src/services/sync/models/index.ts +4 -0
  106. package/src/services/sync/repositories/base.ts +247 -0
  107. package/src/services/sync/repositories/content-likes.ts +26 -0
  108. package/src/services/sync/repositories/content-progress.ts +160 -0
  109. package/src/services/sync/repositories/index.ts +4 -0
  110. package/src/services/sync/repositories/practice-day-notes.ts +4 -0
  111. package/src/services/sync/repositories/practices.ts +52 -0
  112. package/src/services/sync/repository-proxy.ts +48 -0
  113. package/src/services/sync/resolver.ts +84 -0
  114. package/src/services/sync/retry.ts +88 -0
  115. package/src/services/sync/run-scope.ts +30 -0
  116. package/src/services/sync/schema/index.ts +66 -0
  117. package/src/services/sync/serializers/index.ts +2 -0
  118. package/src/services/sync/serializers/model.ts +32 -0
  119. package/src/services/sync/serializers/raw.ts +21 -0
  120. package/src/services/sync/store/index.ts +779 -0
  121. package/src/services/sync/store/push-coalescer.ts +57 -0
  122. package/src/services/sync/store-configs.ts +41 -0
  123. package/src/services/sync/strategies/base.ts +21 -0
  124. package/src/services/sync/strategies/index.ts +12 -0
  125. package/src/services/sync/strategies/initial.ts +11 -0
  126. package/src/services/sync/strategies/polling.ts +54 -0
  127. package/src/services/sync/telemetry/index.ts +140 -0
  128. package/src/services/sync/telemetry/sampling.ts +91 -0
  129. package/src/services/sync/utils/event-emitter.ts +24 -0
  130. package/src/services/sync/utils/index.ts +1 -0
  131. package/src/services/sync/utils/throttle.ts +93 -0
  132. package/src/services/sync/utils/timers.ts +9 -0
  133. package/src/services/userActivity.js +83 -148
  134. package/test/contentProgress.test.js +6 -39
  135. package/test/live/contentProgressLive.test.js +2 -31
  136. package/tools/generate-index.cjs +10 -4
  137. package/babel.config.cjs +0 -3
@@ -0,0 +1,84 @@
1
+ import { RecordId } from "@nozbe/watermelondb";
2
+ import { SyncEntry, SyncEntryNonDeleted } from ".";
3
+ import BaseModel from "./models/Base";
4
+
5
+ export type SyncResolution = {
6
+ entriesForCreate: SyncEntry[]
7
+ tuplesForUpdate: [BaseModel, SyncEntry][]
8
+ tuplesForRestore: [BaseModel, SyncEntry][]
9
+ idsForDestroy: RecordId[]
10
+ }
11
+
12
+ export type SyncResolverComparator<T extends BaseModel = BaseModel> = (serverEntry: SyncEntryNonDeleted<T>, localModel: T) => 'SERVER' | 'LOCAL'
13
+
14
+ export const updatedAtComparator: SyncResolverComparator = (server, local) => {
15
+ return server.meta.lifecycle.updated_at >= local.updated_at ? 'SERVER' : 'LOCAL'
16
+ }
17
+ export default class SyncResolver {
18
+ private resolution: SyncResolution
19
+ private comparator: SyncResolverComparator
20
+
21
+ constructor(comparator?: SyncResolverComparator) {
22
+ this.comparator = comparator || updatedAtComparator
23
+ this.resolution = {
24
+ entriesForCreate: [],
25
+ tuplesForUpdate: [],
26
+ tuplesForRestore: [],
27
+ idsForDestroy: []
28
+ }
29
+ }
30
+
31
+ get result() {
32
+ return { ...this.resolution }
33
+ }
34
+
35
+ againstNone(server: SyncEntry) {
36
+ if (!server.meta.lifecycle.deleted_at) {
37
+ this.resolution.entriesForCreate.push(server)
38
+ }
39
+ }
40
+
41
+ againstSynced(local: BaseModel, server: SyncEntry) {
42
+ if (server.meta.lifecycle.deleted_at) {
43
+ this.resolution.idsForDestroy.push(local.id)
44
+ }
45
+ // take care that the server stamp isn't older than the current local
46
+ // (imagine a race condition where a pull request resolves long after a second one)
47
+ else if (this.comparator(server as SyncEntryNonDeleted<BaseModel>, local) !== 'LOCAL') {
48
+ this.resolution.tuplesForUpdate.push([local, server])
49
+ }
50
+ }
51
+
52
+ // can happen if one tab notifies another of a created record, pushes to server, and other tab pulls
53
+ againstCreated(local: BaseModel, server: SyncEntry) {
54
+ if (server.meta.lifecycle.deleted_at) {
55
+ // delete local even though user has newer changes
56
+ // (we don't ever try to resurrect records here)
57
+ this.resolution.idsForDestroy.push(local.id)
58
+ } else if (this.comparator(server as SyncEntryNonDeleted<BaseModel>, local) !== 'LOCAL') {
59
+ // local is older, so update it with server's
60
+ this.resolution.tuplesForUpdate.push([local, server])
61
+ }
62
+ }
63
+
64
+ againstUpdated(local: BaseModel, server: SyncEntry) {
65
+ if (server.meta.lifecycle.deleted_at) {
66
+ // delete local even though user has newer changes
67
+ // (we don't ever try to resurrect records here)
68
+ this.resolution.idsForDestroy.push(local.id);
69
+ } else if (this.comparator(server as SyncEntryNonDeleted<BaseModel>, local) !== 'LOCAL') {
70
+ // local is older, so update it with server's
71
+ this.resolution.tuplesForUpdate.push([local, server])
72
+ }
73
+ }
74
+
75
+ againstDeleted(local: BaseModel, server: SyncEntry) {
76
+ if (server.meta.lifecycle.deleted_at) {
77
+ this.resolution.idsForDestroy.push(local.id)
78
+ } else if (server.meta.lifecycle.updated_at >= local.updated_at) {
79
+ this.resolution.tuplesForRestore.push([local, server])
80
+ } else {
81
+ this.resolution.idsForDestroy.push(local.id);
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,88 @@
1
+ import SyncContext from "./context"
2
+ import { SyncResponse } from "./fetch"
3
+ import { SyncTelemetry, Span, StartSpanOptions } from "./telemetry/index"
4
+
5
+ export default class SyncRetry {
6
+ private readonly BASE_BACKOFF = 1_000
7
+ private readonly MAX_BACKOFF = 8_000
8
+ private readonly MAX_ATTEMPTS = 4
9
+
10
+ private paused = false
11
+ private backoffUntil = 0
12
+ private failureCount = 0
13
+
14
+ private unsubscribeConnectivity: () => void
15
+
16
+ constructor(private readonly context: SyncContext, private readonly telemetry: SyncTelemetry) {}
17
+
18
+ start() {
19
+ this.unsubscribeConnectivity = this.context.connectivity.subscribe(isOnline => {
20
+ if (isOnline && this.paused) {
21
+ this.paused = false
22
+ this.resetBackoff()
23
+ }
24
+ })
25
+ }
26
+
27
+ stop() {
28
+ this.unsubscribeConnectivity?.()
29
+ }
30
+
31
+ /**
32
+ * Runs the given syncFn with automatic retries.
33
+ * Returns the first successful result or the last failed result after retries.
34
+ */
35
+ async request<T extends SyncResponse>(spanOpts: StartSpanOptions, syncFn: (span: Span) => Promise<T>) {
36
+ let attempt = 0
37
+
38
+ while (true) {
39
+ if (!this.context.connectivity.getValue()) {
40
+ this.telemetry.debug('[Retry] No connectivity - skipping')
41
+ this.paused = true
42
+ return { ok: false } as T
43
+ }
44
+
45
+ const now = Date.now()
46
+ if (now < this.backoffUntil) {
47
+ await this.sleep(this.backoffUntil - now)
48
+ }
49
+
50
+ attempt++
51
+
52
+ const spanOptions = { ...spanOpts, name: `${spanOpts.name}:attempt:${attempt}/${this.MAX_ATTEMPTS}`, op: `${spanOpts.op}:attempt` }
53
+ const result = await this.telemetry.trace(spanOptions, span => syncFn(span))
54
+
55
+ if (result.ok) {
56
+ this.resetBackoff()
57
+ return result
58
+ } else {
59
+ this.scheduleBackoff()
60
+ if (attempt >= this.MAX_ATTEMPTS) return result
61
+ }
62
+ }
63
+ }
64
+
65
+ private resetBackoff() {
66
+ if (this.backoffUntil !== 0 || this.failureCount !== 0) {
67
+ this.telemetry.debug('[Retry] Resetting backoff')
68
+ this.backoffUntil = 0
69
+ this.failureCount = 0
70
+ }
71
+ }
72
+
73
+ private scheduleBackoff() {
74
+ this.failureCount++
75
+
76
+ const exponentialDelay = this.BASE_BACKOFF * Math.pow(2, this.failureCount - 1)
77
+ const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5)
78
+ const delayWithJitter = exponentialDelay + jitter
79
+
80
+ this.backoffUntil = Date.now() + Math.min(this.MAX_BACKOFF, delayWithJitter)
81
+
82
+ this.telemetry.debug('[Retry] Scheduling backoff', { failureCount: this.failureCount, backoffUntil: this.backoffUntil })
83
+ }
84
+
85
+ private sleep(ms: number) {
86
+ return new Promise(resolve => setTimeout(resolve, ms))
87
+ }
88
+ }
@@ -0,0 +1,30 @@
1
+ export default class SyncRunScope {
2
+ private abortController: AbortController
3
+
4
+ constructor() {
5
+ this.abortController = new AbortController()
6
+ }
7
+
8
+ get signal(): AbortSignal {
9
+ return this.abortController.signal
10
+ }
11
+
12
+ abort(): void {
13
+ this.abortController.abort()
14
+ }
15
+
16
+ abortable<T>(fn: () => Promise<T>): Promise<T> {
17
+ return new Promise((resolve, reject) => {
18
+ if (this.signal.aborted) {
19
+ reject(this.signal.reason)
20
+ return
21
+ }
22
+
23
+ fn().then(resolve).catch(reject)
24
+
25
+ this.signal.addEventListener('abort', () => {
26
+ reject(this.signal.reason)
27
+ })
28
+ })
29
+ }
30
+ }
@@ -0,0 +1,66 @@
1
+ import { appSchema, tableSchema } from '@nozbe/watermelondb'
2
+
3
+ export const SYNC_TABLES = {
4
+ CONTENT_LIKES: 'content_likes',
5
+ CONTENT_PROGRESS: 'progress',
6
+ PRACTICES: 'practices',
7
+ PRACTICE_DAY_NOTES: 'practice_day_notes'
8
+ }
9
+
10
+ const contentLikesTable = tableSchema({
11
+ name: SYNC_TABLES.CONTENT_LIKES,
12
+ columns: [
13
+ { name: 'content_id', type: 'number', isIndexed: true },
14
+ { name: 'created_at', type: 'number' },
15
+ { name: 'updated_at', type: 'number' }
16
+ ]
17
+ })
18
+ const contentProgressTable = tableSchema({
19
+ name: SYNC_TABLES.CONTENT_PROGRESS,
20
+ columns: [
21
+ { name: 'content_id', type: 'number', isIndexed: true },
22
+ { name: 'content_brand', type: 'string', isIndexed: true },
23
+ { name: 'collection_type', type: 'string', isOptional: true, isIndexed: true },
24
+ { name: 'collection_id', type: 'number', isOptional: true, isIndexed: true },
25
+ { name: 'state', type: 'string', isIndexed: true },
26
+ { name: 'progress_percent', type: 'number' },
27
+ { name: 'resume_time_seconds', type: 'number' },
28
+ { name: 'created_at', type: 'number' },
29
+ { name: 'updated_at', type: 'number', isIndexed: true }
30
+ ]
31
+ })
32
+ const practicesTable = tableSchema({
33
+ name: SYNC_TABLES.PRACTICES,
34
+ columns: [
35
+ { name: 'manual_id', type: 'string', isOptional: true },
36
+ { name: 'content_id', type: 'number', isOptional: true, isIndexed: true },
37
+ { name: 'date', type: 'string', isIndexed: true },
38
+ { name: 'auto', type: 'boolean', isIndexed: true },
39
+ { name: 'duration_seconds', type: 'number' },
40
+ { name: 'title', type: 'string', isOptional: true },
41
+ { name: 'thumbnail_url', type: 'string', isOptional: true },
42
+ { name: 'category_id', type: 'number', isOptional: true },
43
+ { name: 'instrument_id', type: 'number', isOptional: true },
44
+ { name: 'created_at', type: 'number' },
45
+ { name: 'updated_at', type: 'number', isIndexed: true }
46
+ ]
47
+ })
48
+ const practiceDayNotesTable = tableSchema({
49
+ name: SYNC_TABLES.PRACTICE_DAY_NOTES,
50
+ columns: [
51
+ { name: 'date', type: 'string', isIndexed: true },
52
+ { name: 'notes', type: 'string' },
53
+ { name: 'created_at', type: 'number' },
54
+ { name: 'updated_at', type: 'number', isIndexed: true }
55
+ ]
56
+ })
57
+
58
+ export default appSchema({
59
+ version: 1,
60
+ tables: [
61
+ contentLikesTable,
62
+ contentProgressTable,
63
+ practicesTable,
64
+ practiceDayNotesTable
65
+ ]
66
+ })
@@ -0,0 +1,2 @@
1
+ export { default as RawSerializer, type RawSerialized } from './raw'
2
+ export { default as ModelSerializer, type ModelSerialized } from './model'
@@ -0,0 +1,32 @@
1
+ import { Model, RecordId } from "@nozbe/watermelondb"
2
+ import BaseModel from "../models/Base"
3
+
4
+ export type ModelSerialized<TModel extends Model> = ExtractGetters<TModel>
5
+ type ExtractGetters<T> = {
6
+ [K in keyof T as T[K] extends Function ? never : K]: T[K];
7
+ } & { id: RecordId }
8
+
9
+ // serializes a record to a POJO based on its model getters
10
+ // (essentially strips out all watermelon properties)
11
+ // useful for consumption in components, etc.
12
+
13
+ export default class ModelSerializer<TModel extends Model = Model> {
14
+ toPlainObject(record: TModel) {
15
+ const proto = Object.getPrototypeOf(record)
16
+ const keys = [
17
+ ...Object.getOwnPropertyNames(proto),
18
+ ...Object.getOwnPropertyNames(BaseModel.prototype)
19
+ ]
20
+
21
+ const result = {}
22
+ for (const key of keys) {
23
+ const desc = Object.getOwnPropertyDescriptor(proto, key) || Object.getOwnPropertyDescriptor(BaseModel.prototype, key)
24
+ if (desc?.get) {
25
+ result[key] = desc.get.call(record)
26
+ }
27
+ }
28
+ result['id'] = record.id
29
+
30
+ return result as ModelSerialized<TModel>
31
+ }
32
+ }
@@ -0,0 +1,21 @@
1
+ import { Model } from "@nozbe/watermelondb"
2
+
3
+ export type RawSerialized<TModel extends Model> = Omit<TModel['_raw'], '_changed' | '_status'>
4
+
5
+ // serializes a record to a POJO based on its _raw attributes
6
+ // useful for sending to back-end for sync
7
+
8
+ export default class RawSerializer<TModel extends Model = Model> {
9
+ toPlainObject(model: TModel) {
10
+ const result = {}
11
+ const raw = model._raw
12
+
13
+ for (const key in raw) {
14
+ if (key !== '_changed' && key !== '_status') {
15
+ result[key] = raw[key]
16
+ }
17
+ }
18
+
19
+ return result as RawSerialized<TModel>
20
+ }
21
+ }