musora-content-services 2.131.2 → 2.131.4

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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.131.4](https://github.com/railroadmedia/musora-content-services/compare/v2.131.3...v2.131.4) (2026-02-05)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * hide badge field if is_active false ([#773](https://github.com/railroadmedia/musora-content-services/issues/773)) ([d8d9159](https://github.com/railroadmedia/musora-content-services/commit/d8d9159c5270dac8ea1de92634266f9f412b3c7c))
11
+ * **onboarding:** update recommended content interface ([65f5e91](https://github.com/railroadmedia/musora-content-services/commit/65f5e91eea00762ea5ab195ce650eb1b3fa92e77))
12
+ * watermelon validation tweaks ([#770](https://github.com/railroadmedia/musora-content-services/issues/770)) ([ebd6e7f](https://github.com/railroadmedia/musora-content-services/commit/ebd6e7f7c059a23a6126b993b3084744d3d6e78a))
13
+
14
+ ### [2.131.3](https://github.com/railroadmedia/musora-content-services/compare/v2.131.2...v2.131.3) (2026-02-04)
15
+
5
16
  ### [2.131.2](https://github.com/railroadmedia/musora-content-services/compare/v2.131.1...v2.131.2) (2026-02-04)
6
17
 
7
18
  ### [2.131.1](https://github.com/railroadmedia/musora-content-services/compare/v2.131.0...v2.131.1) (2026-02-04)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.131.2",
3
+ "version": "2.131.4",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -1018,24 +1018,30 @@ export const awardTemplate = {
1018
1018
  * @param {string} brand - brand for if content brand is missing
1019
1019
  * @returns {object|object[]} post-processed content
1020
1020
  */
1021
- export function addAwardTemplateToContent(content, brand= null) {
1021
+ export function postProcessBadge(content, brand = null) {
1022
1022
  if (!content) return content
1023
1023
 
1024
1024
  // should be fine with this; children don't need awards.
1025
1025
  // assumes if badge_logo exists, it needs a badge_template.
1026
1026
  if (Array.isArray(content)) {
1027
- content.forEach((item) => {
1028
- if (item['badge_logo'] && !item['badge_template']) {
1029
- item['badge_template'] = awardTemplate[item['brand'] || brand].front
1030
- item['badge_template_rear'] = awardTemplate[item['brand'] || brand].rear
1031
- }
1027
+ content.map((item) => {
1028
+ return process(item)
1032
1029
  })
1033
1030
  } else {
1034
- if (content['badge_logo'] && !content['badge_template']) {
1035
- content['badge_template'] = awardTemplate[content['brand'] || brand].front
1036
- content['badge_template_rear'] = awardTemplate[content['brand'] || brand].rear
1037
- }
1031
+ content = process(content)
1038
1032
  }
1039
1033
 
1040
1034
  return content
1035
+
1036
+ function process(item) {
1037
+ if (!item['is_active']) {
1038
+ item['badge'] = null
1039
+ item['badge_rear'] = null
1040
+ }
1041
+ if (item['badge_logo'] && !item['badge_template']) {
1042
+ item['badge_template'] = awardTemplate[item['brand'] || brand].front
1043
+ item['badge_template_rear'] = awardTemplate[item['brand'] || brand].rear
1044
+ }
1045
+ return item
1046
+ }
1041
1047
  }
@@ -223,11 +223,7 @@ function defineAwards(data) {
223
223
  return {
224
224
  awardId: def._id,
225
225
  awardTitle: def.name,
226
- badge: def.badge,
227
- badge_rear: def.badge_rear,
228
- badge_logo: def.logo,
229
- badge_template: awardTemplate[def.brand].front,
230
- badge_template_rear: awardTemplate[def.brand].rear,
226
+ ...getBadgeFields(def),
231
227
  award: def.award,
232
228
  brand: def.brand,
233
229
  instructorName: def.instructor_name,
@@ -329,11 +325,7 @@ export async function getCompletedAwards(brand = null, options = {}) {
329
325
  awardId: progress.award_id,
330
326
  awardTitle: definition.name,
331
327
  awardType: definition.type,
332
- badge: definition.badge,
333
- badge_rear: definition.badge_rear,
334
- badge_logo: definition.logo,
335
- badge_template: awardTemplate[definition.brand].front,
336
- badge_template_rear: awardTemplate[definition.brand].rear,
328
+ ...getBadgeFields(definition),
337
329
  award: definition.award,
338
330
  brand: definition.brand,
339
331
  hasCertificate: hasCertificate,
@@ -454,11 +446,7 @@ export async function getInProgressAwards(brand = null, options = {}) {
454
446
  return {
455
447
  awardId: progress.award_id,
456
448
  awardTitle: definition.name,
457
- badge: definition.badge,
458
- badge_rear: definition.badge_rear,
459
- badge_logo: definition.logo,
460
- badge_template: awardTemplate[definition.brand].front,
461
- badge_template_rear: awardTemplate[definition.brand].rear,
449
+ ...getBadgeFields(definition),
462
450
  award: definition.award,
463
451
  brand: definition.brand,
464
452
  instructorName: definition.instructor_name,
@@ -593,3 +581,13 @@ export async function resetAllAwards() {
593
581
  return { deletedCount: 0 }
594
582
  }
595
583
  }
584
+
585
+ function getBadgeFields(def) {
586
+ return {
587
+ badge: def.is_active ? def.badge : null,
588
+ badge_rear: def.is_active ? def.badge_rear : null,
589
+ badge_logo: def.logo,
590
+ badge_template: awardTemplate[def.brand].front,
591
+ badge_template_rear: awardTemplate[def.brand].rear,
592
+ }
593
+ }
@@ -15,7 +15,7 @@ import { addContextToContent } from '../contentAggregator.js'
15
15
  import { fetchPlaylist } from '../content-org/playlists.js'
16
16
  import { TabResponseType } from '../../contentMetaData.js'
17
17
  import { PUT } from '../../infrastructure/http/HttpClient.ts'
18
- import { addAwardTemplateToContent } from "../../contentTypeConfig.js";
18
+ import { postProcessBadge } from "../../contentTypeConfig.js";
19
19
 
20
20
  export const USER_PIN_PROGRESS_KEY = 'user_pin_progress_row'
21
21
 
@@ -142,7 +142,7 @@ async function popPinnedItem(userPinnedItem, contentCardMap, playlistCards, meth
142
142
  } else {
143
143
  // we use fetchByRailContentIds so that we don't have the _type restriction in the query
144
144
  let data = await fetchByRailContentIds([pinnedId], 'progress-tracker')
145
- data = addAwardTemplateToContent(data)
145
+ data = postProcessBadge(data)
146
146
  item = await processContentItem(
147
147
  await addContextToContent(() => data[0] ?? null, {
148
148
  addNextLesson: true,
@@ -5,7 +5,7 @@ import { getAllStartedOrCompleted, getProgressStateByIds } from '../../contentPr
5
5
  import { addContextToContent } from '../../contentAggregator.js'
6
6
  import { fetchByRailContentIds, fetchShows } from '../../sanity.js'
7
7
  import {
8
- addAwardTemplateToContent,
8
+ postProcessBadge,
9
9
  awardTemplate,
10
10
  collectionLessonTypes,
11
11
  getFormattedType,
@@ -46,7 +46,7 @@ export async function getContentCardMap(brand, limit, playlistEngagedOnContent,
46
46
  }
47
47
  )
48
48
  : []
49
- contents = addAwardTemplateToContent(contents)
49
+ contents = postProcessBadge(contents)
50
50
 
51
51
  const contentCards = await Promise.all(generateContentPromises(contents))
52
52
  return contentCards.reduce((contentMap, content) => {
@@ -4,7 +4,7 @@
4
4
  import { fetchUserPlaylists } from '../../content-org/playlists.js'
5
5
  import { addContextToContent } from '../../contentAggregator.js'
6
6
  import { fetchByRailContentIds } from '../../sanity.js'
7
- import { addAwardTemplateToContent } from "../../../contentTypeConfig.js";
7
+ import { postProcessBadge } from "../../../contentTypeConfig.js";
8
8
 
9
9
  export async function getPlaylistCards(recentPlaylists){
10
10
  return await Promise.all(
@@ -74,6 +74,6 @@ export async function getPlaylistEngagedOnContent(recentPlaylists){
74
74
  addProgressTimestamp: true,
75
75
  })
76
76
  : []
77
- contents = addAwardTemplateToContent(contents)
77
+ contents = postProcessBadge(contents)
78
78
  return contents
79
79
  }
@@ -32,7 +32,7 @@ import {
32
32
  SONG_TYPES,
33
33
  SONG_TYPES_WITH_CHILDREN,
34
34
  liveFields,
35
- addAwardTemplateToContent,
35
+ postProcessBadge,
36
36
  contentAwardField,
37
37
  } from '../contentTypeConfig.js'
38
38
  import { fetchSimilarItems } from './recommendations.js'
@@ -998,7 +998,7 @@ export async function fetchLessonContent(railContentId, { addParent = false } =
998
998
  }
999
999
 
1000
1000
  let contents = await fetchSanity(query, false, { customPostProcess: chapterProcess, processNeedAccess: true })
1001
- contents = addAwardTemplateToContent(contents)
1001
+ contents = postProcessBadge(contents)
1002
1002
 
1003
1003
  return contents
1004
1004
  }
@@ -1,9 +1,13 @@
1
1
  import { SyncValidationError } from './index'
2
2
 
3
+ export function throwIfNotInteger(val: any) {
4
+ if (!Number.isSafeInteger(val)) throw new SyncValidationError('Sync value is not a number: ' + val, typeof val, 'number');
5
+ return val
6
+ }
3
7
 
4
8
  export function throwIfNotNumber(val: any) {
5
9
  // note: this will accept decimal values
6
- if (typeof val !== 'number') throw new SyncValidationError('Sync value is not a number: ' + val, typeof val, 'number');
10
+ if (!Number.isFinite(val)) throw new SyncValidationError('Sync value is not a number: ' + val, typeof val, 'number');
7
11
  return val
8
12
  }
9
13
 
@@ -17,6 +21,10 @@ export function throwIfNotBoolean(val: any) {
17
21
  return val
18
22
  }
19
23
 
24
+ export function throwIfNotNullableInteger(val: any) {
25
+ return val === null ? val : throwIfNotInteger(val)
26
+ }
27
+
20
28
  export function throwIfNotNullableNumber(val: any) {
21
29
  return val === null ? val : throwIfNotNumber(val)
22
30
  }
@@ -14,4 +14,9 @@ export default class ContentLike extends BaseModel<{
14
14
  set content_id(value: number) {
15
15
  this._setRaw('content_id', throwIfNotNumber(value))
16
16
  }
17
+
18
+ static generateId(contentId: number) {
19
+ throwIfNotNumber(contentId)
20
+ return contentId.toString()
21
+ }
17
22
  }
@@ -3,9 +3,11 @@ import { SYNC_TABLES } from '../schema'
3
3
  import {
4
4
  throwIfInvalidEnumValue,
5
5
  throwIfNotNullableNumber,
6
+ throwIfNotNullableInteger,
6
7
  throwIfNotNullableString,
7
- throwIfNotNumber,
8
8
  throwIfOutsideRange,
9
+ throwIfNotInteger,
10
+ throwIfNotNumber
9
11
  } from '../errors/validators'
10
12
 
11
13
  export enum COLLECTION_TYPE {
@@ -19,16 +21,44 @@ export enum STATE {
19
21
  COMPLETED = 'completed'
20
22
  }
21
23
 
22
- export default class ContentProgress extends BaseModel<{
23
- content_id: number
24
- content_brand: string | null
25
- collection_type: COLLECTION_TYPE
26
- collection_id: number
27
- state: STATE
28
- progress_percent: number
29
- resume_time_seconds: number | null
30
- last_interacted_a_la_carte: number | null
31
- }> {
24
+ export interface CollectionParameter {
25
+ type: COLLECTION_TYPE,
26
+ id: number,
27
+ }
28
+
29
+ const validators = {
30
+ // unsigned int
31
+ content_id: (contentId: number) => {
32
+ throwIfNotNullableInteger(contentId)
33
+ return throwIfOutsideRange(contentId, 0)
34
+ },
35
+ content_brand: (contentBrand: string | null) => {
36
+ return throwIfNotNullableString(contentBrand)
37
+ },
38
+ // tinyint unsigned - IMPORTANT: progress percent only moves forward and is clamped between 0 and 100
39
+ // also has implications for last-write-wins sync strategy
40
+ progress_percent: (value: number, currentPercent: number) => {
41
+ throwIfNotNumber(value)
42
+ throwIfOutsideRange(value, 0, 100)
43
+ return value === 0 ? 0 : Math.max(value, currentPercent)
44
+ },
45
+ // enum collection_type
46
+ collection_type: (collectionType: string) => {
47
+ return throwIfInvalidEnumValue(collectionType, COLLECTION_TYPE) as COLLECTION_TYPE
48
+ },
49
+ // unsigned mediumint 16777215
50
+ collection_id: (collectionId: number) => {
51
+ throwIfNotInteger(collectionId)
52
+ return throwIfOutsideRange(collectionId, 0, 16777215)
53
+ },
54
+ // smallint unsigned
55
+ resume_time_seconds: (value: number | null) => {
56
+ throwIfNotNullableNumber(value)
57
+ return value !== null ? throwIfOutsideRange(value, 0, 65535) : value
58
+ }
59
+ }
60
+
61
+ export default class ContentProgress extends BaseModel {
32
62
  static table = SYNC_TABLES.CONTENT_PROGRESS
33
63
 
34
64
  get content_id() {
@@ -57,40 +87,41 @@ export default class ContentProgress extends BaseModel<{
57
87
  }
58
88
 
59
89
  set content_id(value: number) {
60
- // unsigned int
61
- throwIfNotNumber(value)
62
- this._setRaw('content_id', throwIfOutsideRange(value, 0))
90
+ this._setRaw('content_id', validators.content_id(value))
63
91
  }
64
92
  set content_brand(value: string | null) {
65
- this._setRaw('content_brand', throwIfNotNullableString(value))
93
+ this._setRaw('content_brand', validators.content_brand(value))
66
94
  }
67
- // IMPORTANT: progress percent only moves forward and is clamped between 0 and 100
68
- // also has implications for last-write-wins sync strategy
69
95
  set progress_percent(value: number) {
70
- // tinyint unsigned
71
- throwIfNotNumber(value)
72
- throwIfOutsideRange(value, 0, 100)
73
- const percent = value === 0 ? 0 : Math.max(value, this.progress_percent)
96
+ const percent = validators.progress_percent(value, this.progress_percent)
74
97
 
75
98
  this._setRaw('progress_percent', percent)
76
99
  this._setRaw('state', percent === 100 ? STATE.COMPLETED : STATE.STARTED)
77
100
  }
78
101
  set collection_type(value: COLLECTION_TYPE) {
79
- // enum collection_type
80
- this._setRaw('collection_type', throwIfInvalidEnumValue(value, COLLECTION_TYPE))
102
+ this._setRaw('collection_type', validators.collection_type(value))
81
103
  }
82
104
  set collection_id(value: number) {
83
- // unsigned mediumint 16777215
84
- throwIfNotNumber(value)
85
- this._setRaw('collection_id', throwIfOutsideRange(value, 0, 16777215))
105
+ this._setRaw('collection_id', validators.collection_id(value))
86
106
  }
87
107
  set resume_time_seconds(value: number | null) {
88
- // smallint unsigned
89
- throwIfNotNullableNumber(value)
90
- this._setRaw('resume_time_seconds', value !== null ? throwIfOutsideRange(value, 0, 65535) : value)
108
+ this._setRaw('resume_time_seconds', validators.resume_time_seconds(value))
91
109
  }
92
110
  set last_interacted_a_la_carte(value: number) {
93
111
  this._setRaw('last_interacted_a_la_carte', value)
94
112
  }
95
113
 
114
+ static generateId(
115
+ contentId: number,
116
+ collection: CollectionParameter | null
117
+ ) {
118
+ validators.content_id(contentId)
119
+
120
+ if (collection !== null) {
121
+ validators.collection_type(collection.type)
122
+ validators.collection_id(collection.id)
123
+ }
124
+
125
+ return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
126
+ }
96
127
  }
@@ -10,6 +10,50 @@ import {
10
10
  throwIfOutsideRange,
11
11
  } from '../errors/validators'
12
12
 
13
+ export const validators = {
14
+ // char(26)
15
+ manual_id: (value: string | null) => {
16
+ throwIfNotNullableString(value)
17
+ return value !== null ? throwIfMaxLengthExceeded(value, 26) : value
18
+ },
19
+ // int unsigned
20
+ content_id: (value: number | null) => {
21
+ throwIfNotNullableNumber(value)
22
+ return value !== null ? throwIfOutsideRange(value, 0) : value
23
+ },
24
+ date: (value: string) => {
25
+ return throwIfNotString(value)
26
+ },
27
+ // tinyint(1)
28
+ auto: (value: boolean) => {
29
+ return throwIfNotBoolean(value)
30
+ },
31
+ duration_seconds: (value: number) => {
32
+ throwIfNotNumber(value)
33
+ return throwIfOutsideRange(value, 0, 59999)
34
+ },
35
+ // varchar(64)
36
+ title: (value: string | null) => {
37
+ throwIfNotNullableString(value)
38
+ return value !== null ? throwIfMaxLengthExceeded(value, 64) : value
39
+ },
40
+ // varchar(255)
41
+ thumbnail_url: (value: string | null) => {
42
+ throwIfNotNullableString(value)
43
+ return value !== null ? throwIfMaxLengthExceeded(value, 255) : value
44
+ },
45
+ // tinyint unsigned
46
+ category_id: (value: number | null) => {
47
+ throwIfNotNullableNumber(value)
48
+ return value !== null ? throwIfOutsideRange(value, 0, 255) : value
49
+ },
50
+ // tinyint unsigned
51
+ instrument_id: (value: number | null) => {
52
+ throwIfNotNullableNumber(value)
53
+ return value !== null ? throwIfOutsideRange(value, 0, 255) : value
54
+ }
55
+ }
56
+
13
57
  export default class Practice extends BaseModel<{
14
58
  manual_id: string | null
15
59
  content_id: number | null
@@ -52,44 +96,41 @@ export default class Practice extends BaseModel<{
52
96
  }
53
97
 
54
98
  set manual_id(value: string | null) {
55
- // char(26)
56
- throwIfNotNullableString(value)
57
- this._setRaw('manual_id', value !== null ? throwIfMaxLengthExceeded(value, 26) : value)
99
+ this._setRaw('manual_id', validators.manual_id(value))
58
100
  }
59
101
  set content_id(value: number | null) {
60
- // int unsigned
61
- throwIfNotNullableNumber(value)
62
- this._setRaw('content_id', value !== null ? throwIfOutsideRange(value, 0) : value)
102
+ this._setRaw('content_id', validators.content_id(value))
63
103
  }
64
104
  set date(value: string) {
65
- this._setRaw('date', throwIfNotString(value))
105
+ this._setRaw('date', validators.date(value))
66
106
  }
67
107
  set auto(value: boolean) {
68
- // tinyint(1)
69
- this._setRaw('auto', throwIfNotBoolean(value))
108
+ this._setRaw('auto', validators.auto(value))
70
109
  }
71
110
  set duration_seconds(value: number) {
72
- throwIfNotNumber(value)
73
- this._setRaw('duration_seconds', throwIfOutsideRange(value, 0, 59999))
111
+ this._setRaw('duration_seconds', validators.duration_seconds(value))
74
112
  }
75
113
  set title(value: string | null) {
76
- // varchar(64)
77
- throwIfNotNullableString(value)
78
- this._setRaw('title', value !== null ? throwIfMaxLengthExceeded(value, 64) : value)
114
+ this._setRaw('title', validators.title(value))
79
115
  }
80
116
  set thumbnail_url(value: string | null) {
81
- // varchar(255)
82
- throwIfNotNullableString(value)
83
- this._setRaw('thumbnail_url', value !== null ? throwIfMaxLengthExceeded(value, 255) : value)
117
+ this._setRaw('thumbnail_url', validators.thumbnail_url(value))
84
118
  }
85
119
  set category_id(value: number | null) {
86
- // tinyint unsigned
87
- throwIfNotNullableNumber(value)
88
- this._setRaw('category_id', value !== null ? throwIfOutsideRange(value, 0, 255) : value)
120
+ this._setRaw('category_id', validators.category_id(value))
89
121
  }
90
122
  set instrument_id(value: number | null) {
91
- // tinyint unsigned
92
- throwIfNotNullableNumber(value)
93
- this._setRaw('instrument_id', value !== null ? throwIfOutsideRange(value, 0, 255) : value)
123
+ this._setRaw('instrument_id', validators.instrument_id(value))
124
+ }
125
+
126
+ static generateAutoId(contentId: number, date: string) {
127
+ throwIfNotNumber(contentId)
128
+ throwIfNotString(date)
129
+ return ['auto', contentId.toString(), date].join(':')
130
+ }
131
+
132
+ static generateManualId(manualId: string) {
133
+ throwIfNotString(manualId)
134
+ return `manual:${manualId}`
94
135
  }
95
136
  }
@@ -3,24 +3,20 @@ import ContentLike from "../models/ContentLike";
3
3
 
4
4
  export default class LikesRepository extends SyncRepository<ContentLike> {
5
5
  async isLiked(contentId: number) {
6
- return await this.existOne(LikesRepository.generateId(contentId))
6
+ return await this.existOne(ContentLike.generateId(contentId))
7
7
  }
8
8
 
9
9
  async areLiked(contentIds: number[]) {
10
- return await this.existSome(contentIds.map(LikesRepository.generateId))
10
+ return await this.existSome(contentIds.map(ContentLike.generateId))
11
11
  }
12
12
 
13
13
  async like(contentId: number) {
14
- return await this.upsertOne(LikesRepository.generateId(contentId), r => {
14
+ return await this.upsertOne(ContentLike.generateId(contentId), r => {
15
15
  r.content_id = contentId;
16
16
  })
17
17
  }
18
18
 
19
19
  async unlike(contentId: number) {
20
- return await this.deleteOne(LikesRepository.generateId(contentId))
21
- }
22
-
23
- private static generateId(contentId: number) {
24
- return contentId.toString();
20
+ return await this.deleteOne(ContentLike.generateId(contentId))
25
21
  }
26
22
  }
@@ -1,5 +1,5 @@
1
1
  import SyncRepository, {Q} from './base'
2
- import ContentProgress, {COLLECTION_ID_SELF, COLLECTION_TYPE, STATE} from '../models/ContentProgress'
2
+ import ContentProgress, {COLLECTION_ID_SELF, COLLECTION_TYPE, STATE, CollectionParameter} from '../models/ContentProgress'
3
3
  import {EpochMs} from "../index";
4
4
 
5
5
  interface ContentIdCollectionTuple {
@@ -7,10 +7,6 @@ interface ContentIdCollectionTuple {
7
7
  collection: CollectionParameter | null,
8
8
  }
9
9
 
10
- export interface CollectionParameter {
11
- type: COLLECTION_TYPE,
12
- id: number,
13
- }
14
10
  export default class ProgressRepository extends SyncRepository<ContentProgress> {
15
11
  // null collection only
16
12
  async startedIds(limit?: number) {
@@ -161,7 +157,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
161
157
  }
162
158
 
163
159
  recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false, fromLearningPath = false} = {}) {
164
- const id = ProgressRepository.generateId(contentId, collection)
160
+ const id = ContentProgress.generateId(contentId, collection)
165
161
 
166
162
  if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
167
163
  fromLearningPath = true
@@ -222,7 +218,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
222
218
 
223
219
  const data = Object.fromEntries(
224
220
  Object.entries(contentProgresses).map(([contentId, progressPct]) => [
225
- ProgressRepository.generateId(+contentId, collection),
221
+ ContentProgress.generateId(+contentId, collection),
226
222
  (r: ContentProgress) => {
227
223
  r.content_id = +contentId
228
224
  r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
@@ -242,11 +238,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
242
238
  }
243
239
 
244
240
  eraseProgress(contentId: number, collection: CollectionParameter | null, {skipPush = false} = {}) {
245
- return this.deleteOne(ProgressRepository.generateId(contentId, collection), { skipPush })
241
+ return this.deleteOne(ContentProgress.generateId(contentId, collection), { skipPush })
246
242
  }
247
243
 
248
244
  eraseProgressMany(contentIds: number[], collection: CollectionParameter | null, {skipPush = false} = {}) {
249
- const ids = contentIds.map((id) => ProgressRepository.generateId(id, collection))
245
+ const ids = contentIds.map((id) => ContentProgress.generateId(id, collection))
250
246
  return this.deleteSome(ids, { skipPush })
251
247
  }
252
248
 
@@ -264,10 +260,4 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
264
260
  )
265
261
  )
266
262
 
267
- private static generateId(
268
- contentId: number,
269
- collection: CollectionParameter | null
270
- ) {
271
- return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
272
- }
273
263
  }
@@ -19,8 +19,8 @@ export default class PracticesRepository extends SyncRepository<Practice> {
19
19
  }
20
20
 
21
21
  async trackAutoPractice(contentId: number, date: string, incrementalDurationSeconds: number, skipPush = true) {
22
- return await this.upsertOne(PracticesRepository.generateAutoId(contentId, date), r => {
23
- r._raw.id = PracticesRepository.generateAutoId(contentId, date);
22
+ return await this.upsertOne(Practice.generateAutoId(contentId, date), r => {
23
+ r._raw.id = Practice.generateAutoId(contentId, date);
24
24
  r.auto = true;
25
25
  r.content_id = contentId;
26
26
  r.date = date;
@@ -32,7 +32,7 @@ export default class PracticesRepository extends SyncRepository<Practice> {
32
32
  async recordManualPractice(date: string, durationSeconds: number, details: Partial<Pick<Practice, 'title' | 'instrument_id' | 'category_id' | 'thumbnail_url'>> = {}) {
33
33
  return await this.insertOne((r) => {
34
34
  const manualId = r._raw.id; // yoink watermelon's autogenerated id
35
- r._raw.id = PracticesRepository.generateManualId(manualId);
35
+ r._raw.id = Practice.generateManualId(manualId);
36
36
 
37
37
  r.manual_id = manualId;
38
38
  r.auto = false;
@@ -57,13 +57,6 @@ export default class PracticesRepository extends SyncRepository<Practice> {
57
57
  })
58
58
  }
59
59
 
60
- private static generateAutoId(contentId: number, date: string) {
61
- return ['auto', contentId.toString(), date].join(':');
62
- }
63
-
64
- private static generateManualId(manualId: string) {
65
- return `manual:${manualId}`;
66
- }
67
60
 
68
61
  async getAutoPracticesOnDate(date: string) {
69
62
  return await this.queryAll(
@@ -206,23 +206,25 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
206
206
  return this.queryRecordId(...args)
207
207
  }
208
208
 
209
- async insertOne(builder: (record: TModel) => void, span?: Span) {
209
+ async insertOne(builder: (record: TModel) => void, parentSpan?: Span) {
210
210
  return await this.runScope.abortable(async () => {
211
- const record = await this.paranoidWrite(span, async () => {
212
- return this.collection.create(rec => {
211
+ const record = await this.paranoidWrite(parentSpan, async (writer, span) => {
212
+ const r = await this.collection.create(rec => {
213
213
  builder(rec)
214
214
  })
215
+ span.setAttribute('records.ids', [r.id])
216
+ return r
215
217
  })
216
218
  this.emit('upserted', [record])
217
219
 
218
- this.pushUnsyncedWithRetry(span, { type: 'insertOne', recordId: record.id })
220
+ this.pushUnsyncedWithRetry(parentSpan, { type: 'insertOne', recordId: record.id })
219
221
  await this.ensurePersistence()
220
222
 
221
223
  return this.modelSerializer.toPlainObject(record)
222
224
  })
223
225
  }
224
226
 
225
- async updateOneId(id: RecordId, builder: (record: TModel) => void, span?: Span) {
227
+ async updateOneId(id: RecordId, builder: (record: TModel) => void, parentSpan?: Span) {
226
228
  return await this.runScope.abortable(async () => {
227
229
  const found = await this.findRecord(id)
228
230
 
@@ -230,59 +232,44 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
230
232
  throw new SyncError('Record not found', { id })
231
233
  }
232
234
 
233
- const record = await this.paranoidWrite(span, async () => {
235
+ const record = await this.paranoidWrite(parentSpan, async (_writer, span) => {
236
+ span.setAttribute('records.ids', [id])
234
237
  return found.update(builder)
235
238
  })
236
239
  this.emit('upserted', [record])
237
240
 
238
- this.pushUnsyncedWithRetry(span, { type: 'updateOneId', recordId: record.id })
241
+ this.pushUnsyncedWithRetry(parentSpan, { type: 'updateOneId', recordIds: record.id })
239
242
  await this.ensurePersistence()
240
243
 
241
244
  return this.modelSerializer.toPlainObject(record)
242
245
  })
243
246
  }
244
247
 
245
- async upsertSome(builders: Record<RecordId, (record: TModel) => void>, span?: Span, { skipPush = false } = {}) {
248
+ async upsertSome(builders: Record<RecordId, (record: TModel) => void>, parentSpan?: Span, { skipPush = false } = {}) {
246
249
  if (Object.keys(builders).length === 0) return []
247
250
 
248
251
  return await this.runScope.abortable(async () => {
249
252
  const ids = Object.keys(builders)
250
253
 
251
- const records = await this.paranoidWrite(span, async writer => {
252
- const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids))))
253
- const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
254
+ const records = await this.paranoidWrite(parentSpan, async (writer, span) => {
255
+ span.setAttribute('records.ids', ids)
254
256
 
257
+ const existing = await writer.callReader(() => {
258
+ return this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))
259
+ })
260
+ const existingMap = existing.reduce((map, record) => map.set(record.id, record), new Map<RecordId, TModel>())
255
261
  const destroyedBuilds = []
256
- const recreateBuilds: Array<{ id: RecordId; created_at: EpochMs; builder: (record: TModel) => void }> = []
257
262
 
258
263
  existing.forEach(record => {
259
264
  if (record._raw._status === 'deleted') {
260
265
  destroyedBuilds.push(new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
261
- } else if (record._raw._status === 'created' && builders[record.id]) {
262
- // Workaround for WatermelonDB bug: prepareUpdate() doesn't commit field changes
263
- // for records with _status='created'. Destroy and recreate to ensure updates persist.
264
- destroyedBuilds.push(new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
265
- recreateBuilds.push({
266
- id: record.id,
267
- created_at: record._raw.created_at,
268
- builder: builders[record.id]
269
- })
270
266
  }
271
267
  })
272
268
 
273
269
  const newBuilds = Object.entries(builders).map(([id, builder]) => {
274
270
  const existing = existingMap.get(id)
275
- const recreate = recreateBuilds.find(r => r.id === id)
276
271
 
277
- if (recreate) {
278
- return this.collection.prepareCreate(record => {
279
- record._raw.id = id
280
- record._raw.created_at = recreate.created_at as EpochMs
281
- record._raw.updated_at = this.generateTimestamp()
282
- record._raw._status = 'created'
283
- builder(record)
284
- })
285
- } else if (existing && existing._raw._status !== 'deleted' && existing._raw._status !== 'created') {
272
+ if (existing && existing._raw._status !== 'deleted') {
286
273
  return existing.prepareUpdate(builder)
287
274
  } else if (!existing || existing._raw._status === 'deleted') {
288
275
  return this.collection.prepareCreate(record => {
@@ -295,7 +282,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
295
282
  })
296
283
  }
297
284
  return null
298
- }).filter((build): build is ReturnType<typeof this.collection.prepareCreate> => build !== null)
285
+ }).filter(build => build !== null)
299
286
 
300
287
  await writer.batch(...destroyedBuilds)
301
288
  await writer.batch(...newBuilds)
@@ -306,7 +293,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
306
293
  this.emit('upserted', records)
307
294
 
308
295
  if (!skipPush) {
309
- this.pushUnsyncedWithRetry(span, { type: 'upsertSome', recordIds: records.map(r => r.id).join(',') })
296
+ this.pushUnsyncedWithRetry(parentSpan, { type: 'upsertSome', recordIds: records.map(r => r.id).join(',') })
310
297
  }
311
298
  await this.ensurePersistence()
312
299
 
@@ -329,11 +316,13 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
329
316
  return this.upsertSomeTentative({ [id]: builder }, span).then(r => r[0])
330
317
  }
331
318
 
332
- async deleteOne(id: RecordId, span?: Span, { skipPush = false } = {}) {
319
+ async deleteOne(id: RecordId, parentSpan?: Span, { skipPush = false } = {}) {
333
320
  return await this.runScope.abortable(async () => {
334
321
  let record: TModel | null = null
335
322
 
336
- await this.paranoidWrite(span, async writer => {
323
+ await this.paranoidWrite(parentSpan, async (writer, span) => {
324
+ span.setAttribute('records.ids', [id])
325
+
337
326
  const existing = await writer.callReader(() => this.queryMaybeDeletedRecords(Q.where('id', id))).then(
338
327
  (records) => records[0] || null
339
328
  )
@@ -355,7 +344,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
355
344
  this.emit('deleted', [id])
356
345
 
357
346
  if (!skipPush) {
358
- this.pushUnsyncedWithRetry(span, { type: 'deleteOne', recordId: id })
347
+ this.pushUnsyncedWithRetry(parentSpan, { type: 'deleteOne', recordId: id })
359
348
  }
360
349
  await this.ensurePersistence()
361
350
 
@@ -363,9 +352,10 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
363
352
  })
364
353
  }
365
354
 
366
- async deleteSome(ids: RecordId[], span?: Span, { skipPush = false } = {}) {
355
+ async deleteSome(ids: RecordId[], parentSpan?: Span, { skipPush = false } = {}) {
367
356
  return this.runScope.abortable(async () => {
368
- await this.paranoidWrite(span, async writer => {
357
+ await this.paranoidWrite(parentSpan, async (writer, span) => {
358
+ span.setAttribute('records.ids', ids)
369
359
  const existing = await this.queryRecords(Q.where('id', Q.oneOf(ids)))
370
360
 
371
361
  await writer.batch(...existing.map(record => record.prepareMarkAsDeleted()))
@@ -374,7 +364,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
374
364
  this.emit('deleted', ids)
375
365
 
376
366
  if (!skipPush) {
377
- this.pushUnsyncedWithRetry(span, { type: 'deleteSome', recordIds: ids.join(',') })
367
+ this.pushUnsyncedWithRetry(parentSpan, { type: 'deleteSome', recordIds: ids.join(',') })
378
368
  }
379
369
  await this.ensurePersistence()
380
370
 
@@ -386,9 +376,11 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
386
376
  return this.restoreSome([id], span).then(r => r[0])
387
377
  }
388
378
 
389
- async restoreSome(ids: RecordId[], span?: Span) {
379
+ async restoreSome(ids: RecordId[], parentSpan?: Span) {
390
380
  return this.runScope.abortable(async () => {
391
- const records = await this.paranoidWrite(span, async writer => {
381
+ const records = await this.paranoidWrite(parentSpan, async (writer, span) => {
382
+ span.setAttribute('records.ids', ids)
383
+
392
384
  const records = await writer.callReader(() => this.queryMaybeDeletedRecords(
393
385
  Q.where('id', Q.oneOf(ids)),
394
386
  Q.where('_status', 'deleted')
@@ -410,7 +402,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
410
402
 
411
403
  this.emit('upserted', records)
412
404
 
413
- this.pushUnsyncedWithRetry(span, { type: 'restoreSome', recordIds: ids.join(',') })
405
+ this.pushUnsyncedWithRetry(parentSpan, { type: 'restoreSome', recordIds: ids.join(',') })
414
406
  await this.ensurePersistence()
415
407
 
416
408
  return records.map((record) => this.modelSerializer.toPlainObject(record))
@@ -806,7 +798,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
806
798
  */
807
799
  private paranoidWrite<T>(
808
800
  parentSpan: Span | undefined,
809
- work: (writer: WriterInterface) => Promise<T>
801
+ work: (writer: WriterInterface, span: Span) => Promise<T>
810
802
  ): Promise<T> {
811
803
  const initialId = this.userScope.initialId
812
804
  const currentId = this.userScope.getCurrentId()
@@ -820,17 +812,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
820
812
  return this.telemetry.trace(
821
813
  { name: `write:${this.model.table}`, op: 'write', parentSpan, attributes: { ...this.context.session.toJSON() } },
822
814
  (writeSpan) => {
823
- return this.db.write(writer =>
824
- this.telemetry.trace(
825
- {
826
- name: `write:generate:${this.model.table}`,
827
- op: 'write:generate',
828
- parentSpan: writeSpan,
829
- attributes: { ...this.context.session.toJSON() },
830
- },
831
- () => work(writer)
832
- )
833
- )
815
+ return this.db.write(writer => work(writer, writeSpan))
834
816
  }
835
817
  )
836
818
  }
@@ -93,8 +93,7 @@ export class SyncTelemetry {
93
93
  op: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.op}`,
94
94
  attributes: {
95
95
  ...opts.attributes,
96
- 'user.initialId': this.userScope.initialId,
97
- 'user.currentId': this.userScope.getCurrentId(),
96
+ 'user.id': this.userScope.initialId
98
97
  },
99
98
  }
100
99
  return this.Sentry.startSpan<T>(options, (span) => {
@@ -48,8 +48,8 @@ export const createSyncSentryTracesSampler = (sampleRate = 0.1) => {
48
48
  return true
49
49
  }
50
50
 
51
- if (attributes?.userId) {
52
- return userBucketedSampler(attributes.userId as string | number, sampleRate)
51
+ if (attributes?.['user.id']) {
52
+ return userBucketedSampler(attributes['user.id'] as string, sampleRate)
53
53
  }
54
54
 
55
55
  return undefined
@@ -129,7 +129,7 @@ export interface OnboardingRecommendedContent {
129
129
  }
130
130
 
131
131
  export interface OnboardingRecommendationResponse {
132
- recommendation: OnboardingRecommendedContent
132
+ recommended_content: OnboardingRecommendedContent
133
133
  user_onboarding: Onboarding
134
134
  }
135
135
 
@@ -8,7 +8,7 @@ import { DataContext, UserActivityVersionKey } from './dataContext.js'
8
8
  import { fetchByRailContentIds, fetchParentChildRelationshipsFor } from './sanity'
9
9
  import { getMonday, getWeekNumber, isSameDate, isNextDay } from './dateUtils.js'
10
10
  import { globalConfig } from './config'
11
- import { addAwardTemplateToContent, getFormattedType } from '../contentTypeConfig'
11
+ import { postProcessBadge, getFormattedType } from '../contentTypeConfig'
12
12
  import dayjs from 'dayjs'
13
13
  import { addContextToContent } from './contentAggregator.js'
14
14
  import { db, Q } from './sync'
@@ -505,7 +505,7 @@ export async function getRecentActivity({ page = 1, limit = 5, tabName = null }
505
505
  addNextLesson: true,
506
506
  }
507
507
  )
508
- contents = addAwardTemplateToContent(contents)
508
+ contents = postProcessBadge(contents)
509
509
 
510
510
  contents = await mapContentsThatWereLastProgressedFromMethod(contents)
511
511
 
@@ -790,7 +790,7 @@ async function formatPracticeMeta(practices = []) {
790
790
  addNextLesson: true,
791
791
  }
792
792
  )
793
- contents = addAwardTemplateToContent(contents)
793
+ contents = postProcessBadge(contents)
794
794
 
795
795
  contents = await mapContentsThatWereLastProgressedFromMethod(contents)
796
796
 
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(npm run lint:*)"
6
- ],
7
- "deny": []
8
- }
9
- }