musora-content-services 2.117.8 → 2.119.0

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,58 @@
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.119.0](https://github.com/railroadmedia/musora-content-services/compare/v2.118.1...v2.119.0) (2026-01-14)
6
+
7
+
8
+ ### Features
9
+
10
+ * adds restore methods (for progress deletion undo) ([#705](https://github.com/railroadmedia/musora-content-services/issues/705)) ([b01a718](https://github.com/railroadmedia/musora-content-services/commit/b01a71892175b38789d62fffac0ce5f8e2e4513d))
11
+
12
+ ### [2.118.1](https://github.com/railroadmedia/musora-content-services/compare/v2.118.0...v2.118.1) (2026-01-14)
13
+
14
+ ## [2.118.0](https://github.com/railroadmedia/musora-content-services/compare/v2.107.4...v2.118.0) (2026-01-14)
15
+
16
+
17
+ ### Features
18
+
19
+ * adds column and implements for hiding cards ([#679](https://github.com/railroadmedia/musora-content-services/issues/679)) ([35ac42c](https://github.com/railroadmedia/musora-content-services/commit/35ac42cebe397c557d0b197d4fab38907ca08ab7))
20
+ * **BEH-1421:** lesson type migrations ([#660](https://github.com/railroadmedia/musora-content-services/issues/660)) ([7f5ab7e](https://github.com/railroadmedia/musora-content-services/commit/7f5ab7e64693b51aea754e0fb13f10edbd7a7958))
21
+ * **BEH-1442:** old method migration + course collection updates ([#673](https://github.com/railroadmedia/musora-content-services/issues/673)) ([24dd6bf](https://github.com/railroadmedia/musora-content-services/commit/24dd6bf6a1604ef3ee639979cf2ffab5abc05a24))
22
+ * **BEH-1491:** proper card ordering (and hiding) on progress row ([#686](https://github.com/railroadmedia/musora-content-services/issues/686)) ([e519c35](https://github.com/railroadmedia/musora-content-services/commit/e519c352ac5e8d09a910b89fa03baf31490da102))
23
+ * extra data for method card ([#691](https://github.com/railroadmedia/musora-content-services/issues/691)) ([36b034c](https://github.com/railroadmedia/musora-content-services/commit/36b034c83b86f0ff825dfc00af6a6b97b793c4c6))
24
+ * **MU2-1323:** Add dynamic filtering to lessons/songs excluding those without avaialable content ([072a471](https://github.com/railroadmedia/musora-content-services/commit/072a471a8b37bb31fc1421375563be0b2f124beb))
25
+ * **MU2-1323:** Remove redundant constant ([768befd](https://github.com/railroadmedia/musora-content-services/commit/768befdf393723cb0e091605f3c7e6ef4d523a92))
26
+ * **MU2-1323:** Use filterTypes for get all content types ([8065100](https://github.com/railroadmedia/musora-content-services/commit/80651009fcc9dae6661b3cbaecf55d5c3585d044))
27
+ * **T3Ps-1324:** Rename Tiered Courses filter to Course Collections ([97efe76](https://github.com/railroadmedia/musora-content-services/commit/97efe76fff3860d769593809bea2e59fb317b528))
28
+ * **T3PS-1413:** homepage progress row learning path lessons need need_access flag ([#688](https://github.com/railroadmedia/musora-content-services/issues/688)) ([cde7359](https://github.com/railroadmedia/musora-content-services/commit/cde73595c76c634b711e580497e291fa074b3d51))
29
+ * **TP-1046:** expose error hooks ([#655](https://github.com/railroadmedia/musora-content-services/issues/655)) ([233fa10](https://github.com/railroadmedia/musora-content-services/commit/233fa1009038448c763d130ec942b9de5a49a875))
30
+ * Use Get requests for Sanity if query under character limit ([#694](https://github.com/railroadmedia/musora-content-services/issues/694)) ([da5c384](https://github.com/railroadmedia/musora-content-services/commit/da5c384e7f538cf7c72a3d8f742787f24a4ed570))
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * adds methods to set/remove http token ([0a460b6](https://github.com/railroadmedia/musora-content-services/commit/0a460b6d002fb7d85c340095d62c1e92ce253d64))
36
+ * **auth:** await for local storage ([69ee2ca](https://github.com/railroadmedia/musora-content-services/commit/69ee2ca780c71a707dfa452ede98240f0cfe8436))
37
+ * **auth:** client platform header on login ([52d5927](https://github.com/railroadmedia/musora-content-services/commit/52d5927fddd79db807440cccd541c09ae8ce770a))
38
+ * **auth:** login data ([a1b72d8](https://github.com/railroadmedia/musora-content-services/commit/a1b72d8145e230cfc850e208f147080c7ef8c6df))
39
+ * **BEH-878:** correct brand song type names ([#695](https://github.com/railroadmedia/musora-content-services/issues/695)) ([2b1cde9](https://github.com/railroadmedia/musora-content-services/commit/2b1cde9978dd744129bd906aff3a9bb03e01b04e))
40
+ * change js import to ts ([#683](https://github.com/railroadmedia/musora-content-services/issues/683)) ([1285cbe](https://github.com/railroadmedia/musora-content-services/commit/1285cbee851a3a2c68a0e36f37909e67173f82b8))
41
+ * changes lesson type mapping ([#690](https://github.com/railroadmedia/musora-content-services/issues/690)) ([af4dda9](https://github.com/railroadmedia/musora-content-services/commit/af4dda9e03eecfd49a341d2c1aea8afcf217e233))
42
+ * daniel merged bad ([#675](https://github.com/railroadmedia/musora-content-services/issues/675)) ([1c5863b](https://github.com/railroadmedia/musora-content-services/commit/1c5863bc921967fe16875367d9589c8dcf4e0ac3))
43
+ * establishes a positive progress validation ([#672](https://github.com/railroadmedia/musora-content-services/issues/672)) ([e9bc211](https://github.com/railroadmedia/musora-content-services/commit/e9bc211659262b282e1073c2746c3c8824371c35))
44
+ * fix explore all results ([#698](https://github.com/railroadmedia/musora-content-services/issues/698)) ([e9eb8a5](https://github.com/railroadmedia/musora-content-services/commit/e9eb8a5744b1d24c2b1b8e7ba5573cd128454463))
45
+ * fixes small bug with aggregator & adds safety for saveContentProgress ([#681](https://github.com/railroadmedia/musora-content-services/issues/681)) ([b3fd0a7](https://github.com/railroadmedia/musora-content-services/commit/b3fd0a7acf1fe01e2aff567148554fb291bac8a7))
46
+ * oops ([#693](https://github.com/railroadmedia/musora-content-services/issues/693)) ([0be66cb](https://github.com/railroadmedia/musora-content-services/commit/0be66cbaef0b33dad3074a22b9670cee4ddd0024))
47
+ * **pins:** user pins local storage ([#704](https://github.com/railroadmedia/musora-content-services/issues/704)) ([ca7c172](https://github.com/railroadmedia/musora-content-services/commit/ca7c1722b759c1a4711a1f6ee2213b7392d2632e))
48
+ * resolve foryou issue on mpf ([#703](https://github.com/railroadmedia/musora-content-services/issues/703)) ([3e8f0dd](https://github.com/railroadmedia/musora-content-services/commit/3e8f0ddc60eb51a5c510df6433e1cca1c5f5496a))
49
+ * Single lessons, Skill Packs and Entertainment tabs not display content and Courses display any content type ([9793443](https://github.com/railroadmedia/musora-content-services/commit/97934438bf8262fa27349eab562cecf1323c720a))
50
+ * some fixes from Rob ([#676](https://github.com/railroadmedia/musora-content-services/issues/676)) ([7e068ee](https://github.com/railroadmedia/musora-content-services/commit/7e068eee3cabc4de1d0620fe58eadf138532ab56))
51
+ * **T3PS-1187:** award progress optimizations ([#689](https://github.com/railroadmedia/musora-content-services/issues/689)) ([5d063b8](https://github.com/railroadmedia/musora-content-services/commit/5d063b8227e9ee5c70c30bc31263296bfcb4aa63))
52
+ * **T3PS-1289:** navigateTo calculation ([#677](https://github.com/railroadmedia/musora-content-services/issues/677)) ([12f4ca3](https://github.com/railroadmedia/musora-content-services/commit/12f4ca38b05a2175b33a9260e7c00f9aebfb8a87))
53
+ * **T3PS-1347:** Update getUserWeeklyStats to include query for past 60 days for streak calculation, retain original contract of the function ([d798acf](https://github.com/railroadmedia/musora-content-services/commit/d798acf2a01f25551746030722a41ccb81030ed9))
54
+ * **T3PS:** Cleanup ([07f057f](https://github.com/railroadmedia/musora-content-services/commit/07f057f867c8a3931ae6cbf5ecca9ae471f23d00))
55
+ * **TP-1051:** group contentProgress upsert pushes ([#665](https://github.com/railroadmedia/musora-content-services/issues/665)) ([27ff11f](https://github.com/railroadmedia/musora-content-services/commit/27ff11fb1b10075313e614cf895356a221f0c0d1))
56
+
5
57
  ### [2.117.8](https://github.com/railroadmedia/musora-content-services/compare/v2.117.7...v2.117.8) (2026-01-13)
6
58
 
7
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.117.8",
3
+ "version": "2.119.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -53,7 +53,7 @@ export class Tabs {
53
53
  static Collections = { name: 'Collections', short_name: 'Collections', value: 'type,collections', cardType: 'big' }
54
54
  static ExploreAll = { name: 'Explore All', short_name: 'Explore All', value: 'tab,explore all', icon: 'icon-filters', cardType: 'big'}
55
55
  static All = { name: 'All', short_name: 'All', value: '' }
56
- static Courses = { name: 'Courses', short_name: 'Courses', value: 'type,Courses' }
56
+ static Courses = { name: 'Courses', short_name: 'Courses', value: 'type,Courses', recSysSection: 'lesson', }
57
57
  static SkillLevel = { name: 'Skill Level', short_name: 'SKILL LEVEL', is_group_by: true, value: 'difficulty_string' }
58
58
  static Genres = { name: 'Genres', short_name: 'Genres', is_group_by: true, value: 'genre' }
59
59
  static Completed = {
@@ -82,12 +82,14 @@ export class Tabs {
82
82
  short_name: 'Tutorials',
83
83
  value: 'type,tutorials',
84
84
  cardType: 'big',
85
+ recSysSection: 'song',
85
86
  }
86
87
  static Transcriptions = {
87
88
  name: 'Transcriptions',
88
89
  short_name: 'Transcriptions',
89
90
  value: 'type,transcriptions',
90
91
  cardType: 'small',
92
+ recSysSection: 'song',
91
93
  }
92
94
  static SheetMusic = {
93
95
  name: 'Sheet Music',
@@ -100,12 +102,14 @@ export class Tabs {
100
102
  short_name: 'Tabs',
101
103
  value: 'type,transcriptions',
102
104
  cardType: 'small',
105
+ recSysSection: 'song',
103
106
  }
104
107
  static PlayAlongs = {
105
108
  name: 'Play-Alongs',
106
109
  short_name: 'Play-Alongs',
107
- value: 'type,play along',
110
+ value: 'type,play-along',
108
111
  cardType: 'small',
112
+ recSysSection: 'song',
109
113
  }
110
114
  static JamTracks = {
111
115
  name: 'Jam Tracks',
@@ -121,9 +125,9 @@ export class Tabs {
121
125
  static RecentActivityPosts = { name: 'Posts', short_name: 'Posts' }
122
126
  static RecentActivityComments = { name: 'Comments', short_name: 'Comments' }
123
127
  // new tabs - 29.10
124
- static SingleLessons = { name: 'Single Lessons', short_name: 'Single Lessons', value: 'type,Single Lessons' }
125
- static SkillPacks = { name: 'Skill Packs', short_name: 'Skill Packs', value: 'type,Skill Packs' }
126
- static Entertainment = { name: 'Entertainment', short_name: 'Entertainment', value: 'type,Entertainment' }
128
+ static SingleLessons = { name: 'Single Lessons', short_name: 'Single Lessons', value: 'type,Single Lessons', recSysSection: 'lesson', }
129
+ static SkillPacks = { name: 'Skill Packs', short_name: 'Skill Packs', value: 'type,Skill Packs', recSysSection: 'lesson', }
130
+ static Entertainment = { name: 'Entertainment', short_name: 'Entertainment', value: 'type,Entertainment', recSysSection: 'lesson', }
127
131
  }
128
132
 
129
133
  /**
package/src/index.d.ts CHANGED
@@ -128,6 +128,7 @@ import {
128
128
  } from './services/contentProgress.js';
129
129
 
130
130
  import {
131
+ clearAllCachedData,
131
132
  verifyLocalDataContext
132
133
  } from './services/dataContext.js';
133
134
 
@@ -444,6 +445,7 @@ declare module 'musora-content-services' {
444
445
  buildEntityAndTotalQuery,
445
446
  buildImageSRC,
446
447
  calculateLongestStreaks,
448
+ clearAllCachedData,
447
449
  closeComment,
448
450
  completeLearningPathIntroVideo,
449
451
  completeMethodIntroVideo,
package/src/index.js CHANGED
@@ -132,6 +132,7 @@ import {
132
132
  } from './services/contentProgress.js';
133
133
 
134
134
  import {
135
+ clearAllCachedData,
135
136
  verifyLocalDataContext
136
137
  } from './services/dataContext.js';
137
138
 
@@ -443,6 +444,7 @@ export {
443
444
  buildEntityAndTotalQuery,
444
445
  buildImageSRC,
445
446
  calculateLongestStreaks,
447
+ clearAllCachedData,
446
448
  closeComment,
447
449
  completeLearningPathIntroVideo,
448
450
  completeMethodIntroVideo,
@@ -18,7 +18,10 @@ import {recommendations, rankCategories, rankItems} from "./recommendations";
18
18
  import {addContextToContent} from "./contentAggregator.js";
19
19
  import {globalConfig} from "./config";
20
20
  import {getUserData} from "./user/management";
21
- import {filterTypes, ownedContentTypes} from "../contentTypeConfig";
21
+ import {
22
+ lessonTypesMapping,
23
+ ownedContentTypes
24
+ } from "../contentTypeConfig";
22
25
  import {getPermissionsAdapter} from "./permissions/index.ts";
23
26
  import {MEMBERSHIP_PERMISSIONS} from "../constants/membership-permissions.ts";
24
27
 
@@ -100,6 +103,7 @@ export async function getTabResults(brand, pageName, tabName, {
100
103
  tabObj => tabObj.name.toLowerCase() === tabName.toLowerCase()
101
104
  )
102
105
  const tabValue = tabMatch?.value || ''
106
+ const tabRecSysSection = tabMatch?.recSysSection || ''
103
107
  const mergedIncludedFields = tabValue ? [...filteredSelectedFilters, tabValue] : filteredSelectedFilters;
104
108
 
105
109
  // Fetch data
@@ -112,23 +116,58 @@ export async function getTabResults(brand, pageName, tabName, {
112
116
  addProgressPercentage: true,
113
117
  addProgressStatus: true
114
118
  })
119
+ } else if (sort === 'recommended') {
120
+ const contentTypes = lessonTypesMapping[tabName.toLowerCase()] || []
121
+ const allRecommendations = await recommendations(brand, { contentTypes, section: tabRecSysSection })
122
+
123
+ let contentToDisplay
124
+ if (allRecommendations.length > 0) {
125
+ // Fetch and sort recommended content
126
+ let recommendedContent = await fetchByRailContentIds(allRecommendations, 'tab-data', brand, true)
127
+ recommendedContent.sort((a, b) => allRecommendations.indexOf(a.id) - allRecommendations.indexOf(b.id))
128
+
129
+ const start = (page - 1) * limit
130
+ const end = start + limit
131
+
132
+ // Need more content beyond recommendations?
133
+ if (recommendedContent.length < end) {
134
+ const additionalNeeded = end - recommendedContent.length;
135
+ const tabData = await fetchTabData(brand, pageName, {
136
+ page: Math.ceil(additionalNeeded / limit),
137
+ limit: additionalNeeded + limit,
138
+ sort: '-published_on',
139
+ includedFields: mergedIncludedFields,
140
+ progress: progressValue
141
+ })
142
+
143
+ // Filter out duplicates and combine
144
+ const recommendedIds = new Set(recommendedContent.map(c => c.id))
145
+ const additionalContent = tabData.entity.filter(c => !recommendedIds.has(c.id))
146
+
147
+ contentToDisplay = [...recommendedContent, ...additionalContent].slice(start, end)
148
+ } else {
149
+ contentToDisplay = recommendedContent.slice(start, end)
150
+ }
151
+ } else {
152
+ // No recommendations - use normal flow
153
+ const temp = await fetchTabData(brand, pageName, { page, limit, sort: '-published_on', includedFields: mergedIncludedFields, progress: progressValue })
154
+ contentToDisplay = temp.entity
155
+ }
156
+
157
+ results = await addContextToContent(() => contentToDisplay, {
158
+ addNextLesson: true,
159
+ addNavigateTo: true,
160
+ addProgressPercentage: true,
161
+ addProgressStatus: true
162
+ })
115
163
  } else {
116
164
  let temp = await fetchTabData(brand, pageName, { page, limit, sort, includedFields: mergedIncludedFields, progress: progressValue });
117
165
 
118
- const [ranking, contextResults] = await Promise.all([
119
- sort === 'recommended' ? rankItems(brand, temp.entity.map(e => e.id)) : [],
120
- addContextToContent(() => temp.entity, {
121
- addNextLesson: true,
122
- addNavigateTo: true,
123
- addProgressPercentage: true,
124
- addProgressStatus: true
125
- })
126
- ]);
127
-
128
- results = ranking.length === 0 ? contextResults : contextResults.sort((a, b) => {
129
- const indexA = ranking.indexOf(a.id);
130
- const indexB = ranking.indexOf(b.id);
131
- return (indexA === -1 ? Infinity : indexA) - (indexB === -1 ? Infinity : indexB);
166
+ results = await addContextToContent(() => temp.entity, {
167
+ addNextLesson: true,
168
+ addNavigateTo: true,
169
+ addProgressPercentage: true,
170
+ addProgressStatus: true
132
171
  })
133
172
  }
134
173
 
@@ -147,3 +147,44 @@ export class DataContext {
147
147
  }
148
148
  }
149
149
  }
150
+
151
+ /**
152
+ * Clears all dataContext cached data from localStorage.
153
+ * Should be called on logout to prevent data leakage between users.
154
+ * Note: Does not clear user_pin_progress_row keys as they are user-specific.
155
+ */
156
+ export async function clearAllCachedData() {
157
+ const storage = globalConfig.localStorage
158
+
159
+ if (storage) {
160
+ const keysToRemove = []
161
+
162
+ // For React Native AsyncStorage
163
+ if (globalConfig.isMA && storage.getAllKeys) {
164
+ const allKeys = await storage.getAllKeys()
165
+ keysToRemove.push(...allKeys.filter(key =>
166
+ key.startsWith('dataContext_')
167
+ ))
168
+ }
169
+ // For web localStorage
170
+ else if (typeof storage.length !== 'undefined') {
171
+ const allKeys = []
172
+ for (let i = 0; i < storage.length; i++) {
173
+ const key = storage.key(i)
174
+ if (key) {
175
+ allKeys.push(key)
176
+ }
177
+ }
178
+ keysToRemove.push(...allKeys.filter(key =>
179
+ key.startsWith('dataContext_')
180
+ ))
181
+ }
182
+
183
+ // Use multiRemove for React Native AsyncStorage, removeItem for web localStorage
184
+ if (storage.multiRemove && typeof storage.multiRemove === 'function') {
185
+ await storage.multiRemove(keysToRemove)
186
+ } else {
187
+ keysToRemove.forEach(key => storage.removeItem(key))
188
+ }
189
+ }
190
+ }
@@ -18,6 +18,14 @@ import { PUT } from '../../infrastructure/http/HttpClient.ts'
18
18
 
19
19
  export const USER_PIN_PROGRESS_KEY = 'user_pin_progress_row'
20
20
 
21
+ /**
22
+ * Gets the localStorage key for user pinned progress, scoped by user ID
23
+ */
24
+ function getUserPinProgressKey() {
25
+ const userId = globalConfig.sessionConfig?.userId || globalConfig.railcontentConfig?.userId
26
+ return userId ? `user_pin_progress_row_${userId}` : USER_PIN_PROGRESS_KEY
27
+ }
28
+
21
29
  /**
22
30
  * Fetches and combines recent user progress rows and playlists, excluding certain types and parents.
23
31
  *
@@ -102,7 +110,8 @@ export async function unpinProgressRow(brand) {
102
110
  }
103
111
 
104
112
  async function getUserPinnedItem(brand) {
105
- const pinnedProgressRaw = await globalConfig.localStorage.getItem(USER_PIN_PROGRESS_KEY)
113
+ const key = getUserPinProgressKey()
114
+ const pinnedProgressRaw = await globalConfig.localStorage.getItem(key)
106
115
  let pinnedProgress = pinnedProgressRaw ? JSON.parse(pinnedProgressRaw) : {}
107
116
  pinnedProgress = pinnedProgress || {}
108
117
  return pinnedProgress[brand] ?? null
@@ -200,9 +209,10 @@ function mergeAndSortItems(items, limit) {
200
209
  }
201
210
 
202
211
  async function updateUserPinnedProgressRow(brand, pinnedData) {
203
- const pinnedProgressRaw = await globalConfig.localStorage.getItem(USER_PIN_PROGRESS_KEY)
212
+ const key = getUserPinProgressKey()
213
+ const pinnedProgressRaw = await globalConfig.localStorage.getItem(key)
204
214
  let pinnedProgress = pinnedProgressRaw ? JSON.parse(pinnedProgressRaw) : {}
205
215
  pinnedProgress = pinnedProgress || {}
206
216
  pinnedProgress[brand] = pinnedData
207
- await globalConfig.localStorage.setItem(USER_PIN_PROGRESS_KEY, JSON.stringify(pinnedProgress))
217
+ await globalConfig.localStorage.setItem(key, JSON.stringify(pinnedProgress))
208
218
  }
@@ -126,9 +126,12 @@ export async function rankItems(brand, content_ids) {
126
126
  }
127
127
  }
128
128
 
129
- export async function recommendations(brand, { section = '' } = {}) {
129
+ export async function recommendations(brand, { section = '', contentTypes = [] } = {}) {
130
130
  section = section.toUpperCase().replace('-', '_')
131
131
  const sectionString = section ? `&section=${section}` : ''
132
- const url = `/api/content/v1/recommendations?brand=${brand}${sectionString}`
132
+ const contentTypesString = contentTypes.length > 0
133
+ ? contentTypes.map(type => `&content_types[]=${encodeURIComponent(type)}`).join('')
134
+ : ''
135
+ const url = `/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
133
136
  return await GET(url)
134
137
  }
@@ -165,6 +165,10 @@ export default class SyncManager {
165
165
  }
166
166
 
167
167
  try {
168
+ Object.values(this.storesRegistry).forEach((store) => {
169
+ store.destroy()
170
+ })
171
+
168
172
  this.runScope.abort()
169
173
  this.strategyMap.forEach(({ strategies }) => strategies.forEach((strategy) => strategy.stop()))
170
174
  effectTeardowns.forEach((teardown) => teardown())
@@ -47,6 +47,10 @@ export default class SyncRepository<TModel extends BaseModel> {
47
47
  return this._respondToRead(() => this.store.queryAllIds(...args))
48
48
  }
49
49
 
50
+ protected async queryAllDeletedIds(...args: Q.Clause[]) {
51
+ return this._respondToRead(() => this.store.queryAllDeletedIds(...args))
52
+ }
53
+
50
54
  protected async fetchOne(id: RecordId) {
51
55
  return this._fetch(() => this.store.readOne(id))
52
56
  }
@@ -131,6 +135,20 @@ export default class SyncRepository<TModel extends BaseModel> {
131
135
  )
132
136
  }
133
137
 
138
+ protected async restoreOne(id: RecordId) {
139
+ return this.store.telemetry.trace(
140
+ { name: `restoreOne:${this.store.model.table}`, op: 'restore', attributes: { ...this.context.session.toJSON() } },
141
+ (span) => this._respondToWrite(() => this.store.restoreOne(id, span), span)
142
+ )
143
+ }
144
+
145
+ protected async restoreSome(ids: RecordId[]) {
146
+ return this.store.telemetry.trace(
147
+ { name: `restoreSome:${this.store.model.table}`, op: 'restore', attributes: { ...this.context.session.toJSON() } },
148
+ (span) => this._respondToWrite(() => this.store.restoreSome(ids, span), span)
149
+ )
150
+ }
151
+
134
152
  private async _respondToWrite<T extends SyncWriteRecordData<TModel>>(create: () => Promise<T>, span?: Span) {
135
153
  const data = await create()
136
154
 
@@ -1,4 +1,4 @@
1
- import { Database, Q, type Collection, type RecordId } from '@nozbe/watermelondb'
1
+ import { Database, Q, Query, type Collection, type RecordId } from '@nozbe/watermelondb'
2
2
  import { RawSerializer, ModelSerializer } from '../serializers'
3
3
  import { ModelClass, SyncToken, SyncEntry, SyncContext, EpochMs } from '..'
4
4
  import { SyncPullResponse, SyncPushResponse, SyncPullFetchFailureResponse, PushPayload, SyncStorePushResultSuccess, SyncStorePushResultFailure } from '../fetch'
@@ -17,7 +17,6 @@ import { type WriterInterface } from '@nozbe/watermelondb/Database/WorkQueue'
17
17
  import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
18
18
  import { SyncError } from '../errors'
19
19
 
20
-
21
20
  type SyncPull = (
22
21
  session: BaseSessionProvider,
23
22
  previousFetchToken: SyncToken | null,
@@ -39,6 +38,8 @@ export type SyncStoreConfig<TModel extends BaseModel = BaseModel> = {
39
38
  export default class SyncStore<TModel extends BaseModel = BaseModel> {
40
39
  static readonly PULL_THROTTLE_INTERVAL = 2_000
41
40
  static readonly PUSH_THROTTLE_INTERVAL = 1_000
41
+ static readonly DELETED_RECORD_GRACE_PERIOD = 60_000 // 60s
42
+ static readonly CLEANUP_INTERVAL = 60_000 * 60 // 1hr
42
43
 
43
44
  readonly telemetry: SyncTelemetry
44
45
  readonly context: SyncContext
@@ -60,6 +61,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
60
61
  private pushCoalescer = new PushCoalescer()
61
62
 
62
63
  private emitter = new EventEmitter()
64
+ private cleanupTimer: NodeJS.Timeout | null = null
63
65
 
64
66
  private lastFetchTokenKey: string
65
67
 
@@ -91,12 +93,18 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
91
93
  this.lastFetchTokenKey = `last_fetch_token:${this.model.table}`
92
94
 
93
95
  this.telemetry = telemetry
96
+
97
+ this.startCleanupTimer()
94
98
  }
95
99
 
96
100
  on = this.emitter.on.bind(this.emitter)
97
101
  off = this.emitter.off.bind(this.emitter)
98
102
  private emit = this.emitter.emit.bind(this.emitter)
99
103
 
104
+ destroy() {
105
+ this.stopCleanupTimer()
106
+ }
107
+
100
108
  async requestSync(reason: string) {
101
109
  inBoundary(ctx => {
102
110
  this.telemetry.trace(
@@ -176,6 +184,10 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
176
184
  return this.queryRecordIds(...args)
177
185
  }
178
186
 
187
+ async queryAllDeletedIds(...args: Q.Clause[]) {
188
+ return this.queryMaybeDeletedRecordIds(...args)
189
+ }
190
+
179
191
  async queryOne(...args: Q.Clause[]) {
180
192
  const record = await this.queryRecord(...args)
181
193
  return record ? this.modelSerializer.toPlainObject(record) : null
@@ -359,6 +371,41 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
359
371
  })
360
372
  }
361
373
 
374
+ async restoreOne(id: RecordId, span?: Span) {
375
+ return this.restoreSome([id], span).then(r => r[0])
376
+ }
377
+
378
+ async restoreSome(ids: RecordId[], span?: Span) {
379
+ return this.runScope.abortable(async () => {
380
+ const records = await this.telemeterizedWrite(span, async writer => {
381
+ const records = await writer.callReader(() => this.queryMaybeDeletedRecords(
382
+ Q.where('id', Q.oneOf(ids)),
383
+ Q.where('_status', 'deleted')
384
+ ))
385
+
386
+ const destroyBuilds = records.map(record => new this.model(this.collection, { id: record.id }).prepareDestroyPermanently())
387
+ const createBuilds = records.map(record => this.collection.prepareCreate((r) => {
388
+ Object.keys(record._raw).forEach((key) => {
389
+ r._raw[key] = record._raw[key]
390
+ })
391
+ r._raw._status = 'updated'
392
+ }))
393
+
394
+ await writer.batch(...destroyBuilds)
395
+ await writer.batch(...createBuilds)
396
+
397
+ return createBuilds
398
+ })
399
+
400
+ this.emit('upserted', records)
401
+
402
+ this.pushUnsyncedWithRetry(span)
403
+ await this.ensurePersistence()
404
+
405
+ return records.map((record) => this.modelSerializer.toPlainObject(record))
406
+ })
407
+ }
408
+
362
409
  async importUpsert(recordRaws: TModel['_raw'][]) {
363
410
  await this.runScope.abortable(async () => {
364
411
  await this.telemeterizedWrite(undefined, async writer => {
@@ -637,31 +684,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
637
684
  return this.db.read(async () => {
638
685
  const undeletedRecords = await this.collection.query(...args).fetch()
639
686
 
640
- const serializedQuery = this.collection.query(...args).serialize()
641
- const adjustedQuery = {
642
- ...serializedQuery,
643
- description: {
644
- ...serializedQuery.description,
645
- where: [
646
- // remove the default "not deleted" clause added by WatermelonDB
647
- ...serializedQuery.description.where.filter(
648
- (w) =>
649
- !(
650
- w.type === 'where' &&
651
- w.left === '_status' &&
652
- w.comparison &&
653
- w.comparison.operator === 'notEq' &&
654
- w.comparison.right &&
655
- 'value' in w.comparison.right &&
656
- w.comparison.right.value === 'deleted'
657
- )
658
- ),
659
-
660
- // and add our own "include deleted" clause
661
- Q.where('_status', Q.eq('deleted'))
662
- ],
663
- },
664
- }
687
+ const adjustedQuery = this.maybeDeletedQuery(this.collection.query(...args))
665
688
 
666
689
  // NOTE: constructing models in this way is a bit of a hack,
667
690
  // but since deleted records aren't "resurrectable" in WatermelonDB anyway,
@@ -682,6 +705,54 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
682
705
  })
683
706
  }
684
707
 
708
+ /**
709
+ * Query records including ones marked as deleted
710
+ * WatermelonDB by default excludes deleted records from queries
711
+ */
712
+ private async queryMaybeDeletedRecordIds(...args: Q.Clause[]) {
713
+ return this.db.read(async () => {
714
+ const undeletedRecordIds = await this.collection.query(...args).fetchIds()
715
+
716
+ const adjustedQuery = this.maybeDeletedQuery(this.collection.query(...args))
717
+ const deletedRecordIds = (await this.db.adapter.unsafeQueryRaw(adjustedQuery)).map(r => r.id)
718
+
719
+ return [
720
+ ...undeletedRecordIds,
721
+ ...deletedRecordIds,
722
+ ]
723
+ })
724
+ }
725
+
726
+ private maybeDeletedQuery(query: Query<TModel>) {
727
+ const serializedQuery = query.serialize()
728
+ const adjustedQuery = {
729
+ ...serializedQuery,
730
+ description: {
731
+ ...serializedQuery.description,
732
+ where: [
733
+ // remove the default "not deleted" clause added by WatermelonDB
734
+ ...serializedQuery.description.where.filter(
735
+ (w) =>
736
+ !(
737
+ w.type === 'where' &&
738
+ w.left === '_status' &&
739
+ w.comparison &&
740
+ w.comparison.operator === 'notEq' &&
741
+ w.comparison.right &&
742
+ 'value' in w.comparison.right &&
743
+ w.comparison.right.value === 'deleted'
744
+ )
745
+ ),
746
+
747
+ // and add our own "include deleted" clause
748
+ Q.where('_status', Q.eq('deleted'))
749
+ ],
750
+ },
751
+ }
752
+
753
+ return adjustedQuery
754
+ }
755
+
685
756
  // Avoid lazy persistence to IndexedDB
686
757
  // to eliminate data loss risk due to tab close/crash before flush to IndexedDB
687
758
  // https://github.com/Nozbe/WatermelonDB/issues/1329
@@ -739,6 +810,9 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
739
810
  }
740
811
 
741
812
  private async buildWriteBatchesFromEntries(writer: WriterInterface, entries: SyncEntry[], freshSync: boolean) {
813
+ // Clean up old deleted records during pull operations
814
+ await this.cleanupOldDeletedRecords(writer)
815
+
742
816
  // if this is a fresh sync and there are no existing records, we can skip more sophisticated conflict resolution
743
817
  if (freshSync) {
744
818
  if ((await writer.callReader(() => this.queryMaybeDeletedRecords())).length === 0) {
@@ -747,7 +821,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
747
821
  .filter((e) => !e.meta.lifecycle.deleted_at)
748
822
  .forEach((entry) => resolver.againstNone(entry))
749
823
 
750
- return this.prepareRecords(resolver.result)
824
+ return this.prepareRecords(resolver.result, new Map())
751
825
  }
752
826
  }
753
827
 
@@ -789,17 +863,26 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
789
863
  }
790
864
  })
791
865
 
792
- return this.prepareRecords(resolver.result)
866
+ return this.prepareRecords(resolver.result, existingRecordsMap)
793
867
  }
794
868
 
795
- private prepareRecords(result: SyncResolution) {
869
+ private prepareRecords(result: SyncResolution, existingRecordsMap: Map<RecordId, TModel>) {
796
870
  if (Object.values(result).find((c) => c.length)) {
797
871
  this.telemetry.debug(`[store:${this.model.table}] Writing changes`, { changes: result })
798
872
  }
799
873
 
800
- const destroyedBuilds = result.idsForDestroy.map((id) => {
801
- return new this.model(this.collection, { id }).prepareDestroyPermanently()
802
- })
874
+ const destroyedBuilds = result.idsForDestroy
875
+ .filter(id => {
876
+ // Only permanently delete if updated_at is older than grace period
877
+ const record = existingRecordsMap.get(id)
878
+ if (!record) return true // If no record found, safe to destroy
879
+
880
+ const gracePeriodAgo = Date.now() - SyncStore.DELETED_RECORD_GRACE_PERIOD
881
+ return record.updated_at < gracePeriodAgo
882
+ })
883
+ .map((id) => {
884
+ return new this.model(this.collection, { id }).prepareDestroyPermanently()
885
+ })
803
886
  const createdBuilds = result.entriesForCreate.map((entry) => {
804
887
  return this.collection.prepareCreate((r) => {
805
888
  Object.entries(entry.record!).forEach(([key, value]) => {
@@ -856,4 +939,42 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
856
939
  private isLokiAdapter(adapter: any): adapter is LokiJSAdapter {
857
940
  return adapter._driver && 'loki' in adapter._driver
858
941
  }
942
+
943
+ private startCleanupTimer() {
944
+ this.cleanupTimer = setInterval(() => {
945
+ this.runScope.abortable(async () => {
946
+ this.telemeterizedWrite(undefined, async (writer) => {
947
+ await this.cleanupOldDeletedRecords(writer)
948
+ })
949
+ })
950
+ }, SyncStore.CLEANUP_INTERVAL)
951
+ }
952
+
953
+ private stopCleanupTimer() {
954
+ if (this.cleanupTimer) {
955
+ clearInterval(this.cleanupTimer)
956
+ this.cleanupTimer = null
957
+ }
958
+ }
959
+
960
+ /** Destroy permanently records past their grace period
961
+ * (we need to keep records around after being marked deleted
962
+ * for undo purposes, so we don't discard them in writeEntries
963
+ * (after a server push), but instead every hour or so)
964
+ */
965
+ private async cleanupOldDeletedRecords(writer: WriterInterface) {
966
+ const gracePeriodAgo = Date.now() - SyncStore.DELETED_RECORD_GRACE_PERIOD
967
+
968
+ const oldDeletedRecords = await writer.callReader(() => this.queryMaybeDeletedRecords(
969
+ Q.where('_status', 'deleted'),
970
+ Q.where('updated_at', Q.lt(gracePeriodAgo))
971
+ ))
972
+
973
+ if (oldDeletedRecords.length > 0) {
974
+ this.telemetry.debug(`[store:${this.model.table}] Cleaning up ${oldDeletedRecords.length} old deleted records`)
975
+
976
+ const destroyBuilds = oldDeletedRecords.map(record => record.prepareDestroyPermanently())
977
+ return writer.batch(...destroyBuilds)
978
+ }
979
+ }
859
980
  }
@@ -2,6 +2,7 @@
2
2
  * @module Sessions
3
3
  */
4
4
  import { globalConfig } from '../config.js'
5
+ import { clearAllCachedData } from '../dataContext.js'
5
6
  import { USER_PIN_PROGRESS_KEY } from '../progress-row/base.js'
6
7
  import './types.js'
7
8
 
@@ -50,9 +51,11 @@ export async function login(email, password, deviceName, deviceToken, platform)
50
51
 
51
52
  // TODO: refactor this. I don't think this is the place for it but we need it fixed for the system test
52
53
  if (res.ok) {
54
+ const userId = data.user?.id
55
+ const userPinKey = userId ? `user_pin_progress_row_${userId}` : USER_PIN_PROGRESS_KEY
53
56
  await globalConfig.localStorage.setItem(
54
- USER_PIN_PROGRESS_KEY,
55
- JSON.stringify(data.pinned_progress_rows || {})
57
+ userPinKey,
58
+ JSON.stringify(data.user?.brand_pinned_progress || {})
56
59
  )
57
60
  }
58
61
 
@@ -91,6 +94,7 @@ export async function login(email, password, deviceName, deviceToken, platform)
91
94
 
92
95
  /**
93
96
  * Logs the user out of the current session.
97
+ * Clears all cached data to prevent data leakage between users.
94
98
  *
95
99
  * @returns {Promise<void>}
96
100
  *
@@ -108,6 +112,9 @@ export async function logout() {
108
112
  'Content-Type': 'application/json',
109
113
  },
110
114
  })
115
+
116
+ // Clear all locally cached data to prevent data leakage between users
117
+ await clearAllCachedData()
111
118
  }
112
119
 
113
120
  /**
@@ -341,7 +341,7 @@ export async function removeUserPractice(id) {
341
341
  }
342
342
 
343
343
  /**
344
- * Restores a previously deleted user's practice session by ID, updating both the local and remote activity context.
344
+ * Restores a previously deleted user's practice session by ID
345
345
  *
346
346
  * @param {number} id - The unique identifier of the practice session to be restored.
347
347
  * @returns {Promise<Object>} - A promise that resolves to the response containing the restored practice session data.
@@ -353,35 +353,7 @@ export async function removeUserPractice(id) {
353
353
  * .catch(error => console.error(error));
354
354
  */
355
355
  export async function restoreUserPractice(id) {
356
- const url = `/api/user/practices/v1/practices/restore${buildQueryString([id])}`
357
- const response = await PUT(url, null)
358
- if (response?.data?.length) {
359
- const restoredPractice = response.data.find((p) => p.id === id)
360
- if (restoredPractice) {
361
- await userActivityContext.updateLocal(async function (localContext) {
362
- if (!localContext.data[DATA_KEY_PRACTICES][restoredPractice.day]) {
363
- localContext.data[DATA_KEY_PRACTICES][restoredPractice.day] = []
364
- }
365
- response.data.forEach((restoredPractice) => {
366
- localContext.data[DATA_KEY_PRACTICES][restoredPractice.day].push({
367
- id: restoredPractice.id,
368
- duration_seconds: restoredPractice.duration_seconds,
369
- })
370
- })
371
- })
372
- }
373
- }
374
- const formattedMeta = await formatPracticeMeta(response.data || [])
375
- const practiceDuration = formattedMeta.reduce(
376
- (total, practice) => total + (practice.duration || 0),
377
- 0
378
- )
379
- return {
380
- data: formattedMeta,
381
- message: response.message,
382
- version: response.version,
383
- practiceDuration,
384
- }
356
+ return await db.practices.restoreOne(id)
385
357
  }
386
358
 
387
359
  /**
@@ -422,25 +394,10 @@ export async function deletePracticeSession(day) {
422
394
  * .catch(error => console.error("Restore failed:", error));
423
395
  */
424
396
  export async function restorePracticeSession(date) {
425
- const url = `/api/user/practices/v1/practices/restore?date=${date}`
426
- const response = await PUT(url, null)
427
-
428
- if (response?.data) {
429
- await userActivityContext.updateLocal(async function (localContext) {
430
- if (!localContext.data[DATA_KEY_PRACTICES][date]) {
431
- localContext.data[DATA_KEY_PRACTICES][date] = []
432
- }
433
-
434
- response.data.forEach((restoredPractice) => {
435
- localContext.data[DATA_KEY_PRACTICES][date].push({
436
- id: restoredPractice.id,
437
- duration_seconds: restoredPractice.duration_seconds,
438
- })
439
- })
440
- })
441
- }
397
+ const ids = await db.practices.queryAllDeletedIds(Q.where('date', date))
398
+ const response = await db.practices.restoreSome(ids.data)
442
399
 
443
- const formattedMeta = await formatPracticeMeta(response?.data)
400
+ const formattedMeta = await formatPracticeMeta(response.data)
444
401
  const practiceDuration = formattedMeta.reduce(
445
402
  (total, practice) => total + (practice.duration || 0),
446
403
  0