musora-content-services 2.107.8 → 2.110.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 (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CLAUDE.md +2 -2
  3. package/package.json +1 -1
  4. package/src/contentMetaData.js +0 -5
  5. package/src/contentTypeConfig.js +13 -73
  6. package/src/index.d.ts +0 -20
  7. package/src/index.js +0 -20
  8. package/src/services/content.js +1 -1
  9. package/src/services/contentAggregator.js +1 -4
  10. package/src/services/contentProgress.js +13 -8
  11. package/src/services/railcontent.js +0 -163
  12. package/src/services/sanity.js +258 -7
  13. package/src/services/sync/adapters/factory.ts +6 -6
  14. package/src/services/sync/adapters/lokijs.ts +174 -1
  15. package/src/services/sync/context/providers/durability.ts +1 -0
  16. package/src/services/sync/database/factory.ts +12 -5
  17. package/src/services/sync/effects/index.ts +6 -0
  18. package/src/services/sync/effects/logout-warning.ts +47 -0
  19. package/src/services/sync/errors/boundary.ts +4 -6
  20. package/src/services/sync/errors/index.ts +16 -0
  21. package/src/services/sync/fetch.ts +5 -5
  22. package/src/services/sync/manager.ts +80 -40
  23. package/src/services/sync/models/ContentProgress.ts +6 -0
  24. package/src/services/sync/repositories/base.ts +1 -8
  25. package/src/services/sync/repositories/content-progress.ts +8 -2
  26. package/src/services/sync/retry.ts +4 -4
  27. package/src/services/sync/schema/index.ts +1 -0
  28. package/src/services/sync/store/index.ts +34 -31
  29. package/src/services/sync/store/push-coalescer.ts +3 -3
  30. package/src/services/sync/store-configs.ts +10 -8
  31. package/src/services/sync/telemetry/flood-prevention.ts +27 -0
  32. package/src/services/sync/telemetry/index.ts +71 -9
  33. package/src/services/sync/telemetry/sampling.ts +2 -6
  34. package/src/services/user/types.d.ts +0 -7
  35. package/test/sync/adapter.ts +2 -34
  36. package/test/sync/initialize-sync-manager.js +8 -25
  37. package/.claude/settings.local.json +0 -9
  38. package/src/services/sync/concurrency-safety.ts +0 -4
@@ -11,97 +11,6 @@ import { GET, POST, PUT, DELETE } from '../infrastructure/http/HttpClient.ts'
11
11
  */
12
12
  const excludeFromGeneratedIndex = ['fetchUserPermissionsData']
13
13
 
14
- /**
15
- * Fetches the completion status of a specific lesson for the current user.
16
- *
17
- * @param {string} content_id - The ID of the lesson content to check.
18
- * @returns {Promise<Object|null>} - Returns the completion status object if found, otherwise null.
19
- * @example
20
- * fetchCurrentSongComplete('user123', 'lesson456', 'csrf-token')
21
- * .then(status => console.log(status))
22
- * .catch(error => console.error(error));
23
- */
24
- export async function fetchCompletedState(content_id) {
25
- const url = `/content/user_progress/${globalConfig.sessionConfig.userId}?content_ids[]=${content_id}`
26
- const result = await GET(url)
27
-
28
- if (result && result[content_id]) {
29
- return result[content_id]
30
- }
31
- return null
32
- }
33
-
34
- /**
35
- * Fetches the completion status for multiple songs for the current user.
36
- *
37
- * @param {Array<string>} contentIds - An array of content IDs to check.
38
- * @returns {Promise<Object|null>} - Returns an object containing completion statuses keyed by content ID, or null if an error occurs.
39
- * @example
40
- * fetchAllCompletedStates('user123', ['song456', 'song789'], 'csrf-token')
41
- * .then(statuses => console.log(statuses))
42
- * .catch(error => console.error(error));
43
- */
44
- export async function fetchAllCompletedStates(contentIds) {
45
- const url = `/content/user_progress/${globalConfig.sessionConfig.userId}?${contentIds.map((id) => `content_ids[]=${id}`).join('&')}`
46
- return await GET(url)
47
- }
48
-
49
- /**
50
- * Fetches a list of songs that are currently in progress for the current user.
51
- *
52
- * @param {string} brand - The brand associated with the songs.
53
- * @returns {Promise<Object|null>} - Returns an object containing in-progress songs if found, otherwise null.
54
- * @example
55
- * fetchSongsInProgress('drumeo')
56
- * .then(songs => console.log(songs))
57
- * .catch(error => console.error(error));
58
- */
59
- export async function fetchSongsInProgress(brand) {
60
- const url = `/content/in_progress/${globalConfig.sessionConfig.userId}?content_type=song&brand=${brand}`
61
- return await GET(url)
62
- }
63
-
64
- /**
65
- * Fetches a list of content that is currently in progress for the current user.
66
- *
67
- * @param {string} type - The content type associated with the content.
68
- * @param {string} brand - The brand associated with the content.
69
- * @param {number} [options.limit=20] - The limit of results per page.
70
- * @param {number} [options.page=1] - The page number for pagination.
71
- * @returns {Promise<Object|null>} - Returns an object containing in-progress content if found, otherwise null.
72
- * @example
73
- * fetchContentInProgress('song', 'drumeo')
74
- * .then(songs => console.log(songs))
75
- * .catch(error => console.error(error));
76
- */
77
- export async function fetchContentInProgress(type = 'all', brand, { page, limit } = {}) {
78
- const limitString = limit ? `&limit=${limit}` : ''
79
- const pageString = page ? `&page=${page}` : ''
80
- const contentTypeParam = type === 'all' ? '' : `content_type=${type}&`
81
- const url = `/content/in_progress/${globalConfig.sessionConfig.userId}?${contentTypeParam}brand=${brand}${limitString}${pageString}`
82
- return await GET(url)
83
- }
84
-
85
- /**
86
- * Fetches a list of content that has been completed for the current user.
87
- *
88
- * @param {string} type - The content type associated with the content.
89
- * @param {string} brand - The brand associated with the content.
90
- * @param {number} [options.limit=20] - The limit of results per page.
91
- * @param {number} [options.page=1] - The page number for pagination.
92
- * @returns {Promise<Object|null>} - Returns an object containing in-progress content if found, otherwise null.
93
- * @example
94
- * fetchCompletedContent('song', 'drumeo')
95
- * .then(songs => console.log(songs))
96
- * .catch(error => console.error(error));
97
- */
98
- export async function fetchCompletedContent(type = 'all', brand, { page, limit } = {}) {
99
- const limitString = limit ? `&limit=${limit}` : ''
100
- const pageString = page ? `&page=${page}` : ''
101
- const contentTypeParam = type === 'all' ? '' : `content_type=${type}&`
102
- const url = `/content/completed/${globalConfig.sessionConfig.userId}?${contentTypeParam}brand=${brand}${limitString}${pageString}`
103
- return await GET(url)
104
- }
105
14
 
106
15
  /**
107
16
  * Fetches user context data for a specific piece of content.
@@ -118,18 +27,6 @@ export async function fetchContentPageUserData(contentId) {
118
27
  return await GET(url)
119
28
  }
120
29
 
121
- /**
122
- * Fetches the ID and Type of the piece of content that would be the next one for the user
123
- *
124
- * @param {int} contentId - The id of the parent (method, level, or course) piece of content.
125
- * @returns {Promise<Object|null>} - Returns and Object with the id and type of the next piece of content if found, otherwise null.
126
- */
127
- export async function fetchNextContentDataForParent(contentId) {
128
- const url = `/content/${contentId}/next/${globalConfig.sessionConfig.userId}`
129
- const result = await GET(url)
130
- return result?.next ?? null
131
- }
132
-
133
30
  export async function fetchUserPermissionsData() {
134
31
  const url = `/content/user/permissions`
135
32
  return (await GET(url)) ?? []
@@ -145,40 +42,6 @@ export async function postPlaylistContentEngaged(playlistItemId) {
145
42
  return await POST(url, null)
146
43
  }
147
44
 
148
- /**
149
- * Fetch the user's best award for this challenge
150
- *
151
- * @param contentId - railcontent id of the challenge
152
- * @returns {Promise<any|null>} - streamed PDF
153
- */
154
- export async function fetchUserAward(contentId) {
155
- const url = `/challenges/download_award/${contentId}`
156
- return await GET(url)
157
- }
158
-
159
- /**
160
- * Fetch All Carousel Card Data
161
- *
162
- * @returns {Promise<any|null>}
163
- */
164
- export async function fetchCarouselCardData(brand = null) {
165
- const brandParam = brand ? `?brand=${brand}` : ''
166
- const url = `/api/v2/content/carousel${brandParam}`
167
- return await GET(url)
168
- }
169
-
170
- /**
171
- * Fetch all completed badges for the user ordered by completion date descending
172
- *
173
- * @param {string|null} brand -
174
- * @returns {Promise<any|null>}
175
- */
176
- export async function fetchUserBadges(brand = null) {
177
- const brandParam = brand ? `?brand=${brand}` : ''
178
- const url = `/challenges/user_badges/get${brandParam}`
179
- return await GET(url)
180
- }
181
-
182
45
  /**
183
46
  * Set a user's StudentView Flag
184
47
  *
@@ -473,32 +336,6 @@ export async function fetchUserPracticeNotes(date) {
473
336
  return await GET(url)
474
337
  }
475
338
 
476
- /**
477
- * Get the id and slug of last interacted child. Only valid for certain content types
478
- *
479
- * @async
480
- * @function fetchLastInteractedChild
481
- * @param {array} content_ids - Content ids of to get the last interacted child of
482
- *
483
- *
484
- * @returns {Promise<Object>} - keyed object per valid content ids with the child
485
- *
486
- * @example
487
- * try {
488
- * const response = await fetchLastInteractedChild([191369, 410427]);
489
- * console.log('child id', response[191369].content_id)
490
- * console.log('child slug', response[191369].slug)
491
- * } catch (error) {
492
- * console.error('Failed to get children', error);
493
- * }
494
- */
495
- export async function fetchLastInteractedChild(content_ids) {
496
- const params = new URLSearchParams()
497
- content_ids.forEach((id) => params.append('content_ids[]', id))
498
- const url = `/api/content/v1/user/last_interacted_child?${params.toString()}`
499
- return await GET(url)
500
- }
501
-
502
339
  /**
503
340
  * @typedef {Object} Activity
504
341
  * @property {string} id - Unique identifier for the activity.
@@ -17,18 +17,27 @@ import {
17
17
  getNewReleasesTypes,
18
18
  getUpcomingEventsTypes,
19
19
  instructorField,
20
+ lessonTypesMapping,
21
+ individualLessonsTypes,
22
+ coursesLessonTypes,
23
+ skillLessonTypes,
24
+ entertainmentLessonTypes,
25
+ filterTypes,
26
+ tutorialsLessonTypes,
27
+ transcriptionsLessonTypes,
28
+ playAlongLessonTypes,
29
+ jamTrackLessonTypes,
20
30
  resourcesField,
21
31
  showsTypes,
22
32
  SONG_TYPES,
23
33
  SONG_TYPES_WITH_CHILDREN,
24
34
  } from '../contentTypeConfig.js'
25
- import { fetchSimilarItems } from './recommendations.js'
35
+ import { fetchSimilarItems, recommendations } from './recommendations.js'
26
36
  import { processMetadata } from '../contentMetaData.js'
27
37
  import { GET } from '../infrastructure/http/HttpClient.ts'
28
38
 
29
39
  import { globalConfig } from './config.js'
30
40
 
31
- import { fetchNextContentDataForParent } from './railcontent.js'
32
41
  import { arrayToStringRepresentation, FilterBuilder } from '../filterBuilder.js'
33
42
  import { getPermissionsAdapter } from './permissions/index.ts'
34
43
  import { getAllCompleted, getAllStarted, getAllStartedOrCompleted } from './contentProgress.js'
@@ -41,6 +50,29 @@ import { fetchRecentActivitiesActiveTabs } from './userActivity.js'
41
50
  */
42
51
  const excludeFromGeneratedIndex = ['fetchRelatedByLicense']
43
52
 
53
+ /**
54
+ * Song/Lesson tabs that are always visible.
55
+ *
56
+ * @type {string[]}
57
+ */
58
+ const ALWAYS_VISIBLE_TABS = ['For You', 'Explore All'];
59
+
60
+ /**
61
+ * Mapping from tab names to their underlying Sanity content types.
62
+ * Used to determine if a tab has any content available.
63
+ * @type {Object.<string, string[]>}
64
+ */
65
+ const TAB_TO_CONTENT_TYPES = {
66
+ 'Single Lessons': individualLessonsTypes,
67
+ 'Courses': coursesLessonTypes,
68
+ 'Skill Packs': skillLessonTypes,
69
+ 'Entertainment': entertainmentLessonTypes,
70
+ 'Tutorials': tutorialsLessonTypes,
71
+ 'Transcriptions': transcriptionsLessonTypes,
72
+ 'Play-Alongs': playAlongLessonTypes,
73
+ 'Jam Tracks': jamTrackLessonTypes,
74
+ };
75
+
44
76
  /**
45
77
  * Fetch a song by its document ID from Sanity.
46
78
  *
@@ -1597,21 +1629,52 @@ export async function fetchShowsData(brand) {
1597
1629
  *
1598
1630
  * @param {string} brand - The brand for which to fetch metadata.
1599
1631
  * @param {string} type - The type for which to fetch metadata.
1632
+ * @param {Object|boolean} [options={}] - Options object or legacy boolean for withFilters
1633
+ * @param {boolean} [options.skipTabFiltering=false] - Skip dynamic tab filtering (internal use)
1600
1634
  * @returns {Promise<{name, description, type: *, thumbnailUrl}>}
1601
1635
  *
1602
1636
  * @example
1637
+ * // Standard usage (with tab filtering)
1638
+ * fetchMetadata('drumeo', 'lessons')
1603
1639
  *
1604
- * fetchMetadata('drumeo','song')
1605
- * .then(data => console.log(data))
1606
- * .catch(error => console.error(error));
1640
+ * @example
1641
+ * // Internal usage (skip tab filtering to prevent recursion)
1642
+ * fetchMetadata('drumeo', 'lessons', { skipTabFiltering: true })
1607
1643
  */
1608
- export async function fetchMetadata(brand, type) {
1609
- let processedData = processMetadata(brand, type, true)
1644
+ export async function fetchMetadata(brand, type, options = {}) {
1645
+ // Handle backward compatibility - type was previously the 3rd param (boolean)
1646
+ const withFilters = typeof options === 'boolean' ? options : true;
1647
+ const skipTabFiltering = options.skipTabFiltering || false;
1648
+ let processedData = processMetadata(brand, type, withFilters)
1649
+
1610
1650
  if (processedData?.onlyAvailableTabs === true) {
1611
1651
  const activeTabs = await fetchRecentActivitiesActiveTabs()
1612
1652
  processedData.tabs = activeTabs
1613
1653
  }
1614
1654
 
1655
+ if ((type === 'lessons' || type === 'songs') && !skipTabFiltering) {
1656
+ try {
1657
+ // Single API call to get all content type counts
1658
+ const contentTypeCounts = await fetchContentTypeCounts(brand, type);
1659
+
1660
+ // Filter tabs based on counts
1661
+ processedData.tabs = filterTabsByContentCounts(
1662
+ processedData.tabs,
1663
+ contentTypeCounts
1664
+ );
1665
+
1666
+ // Filter Type options based on counts
1667
+ if (processedData.filters) {
1668
+ processedData.filters = filterTypeOptionsByContentCounts(
1669
+ processedData.filters,
1670
+ contentTypeCounts
1671
+ );
1672
+ }
1673
+ } catch (error) {
1674
+ console.error('Error fetching content type counts, using all tabs/filters:', error);
1675
+ // Fail open - show all tabs and filters
1676
+ }
1677
+ }
1615
1678
  return processedData ? processedData : {}
1616
1679
  }
1617
1680
 
@@ -2107,3 +2170,191 @@ export async function fetchBrandsByContentIds(contentIds) {
2107
2170
  })
2108
2171
  return brandMap
2109
2172
  }
2173
+
2174
+ /**
2175
+ * Get all possible content types for a page type (lessons or songs).
2176
+ * Returns unique array of Sanity content type strings.
2177
+ * Uses the existing filterTypes mapping from contentTypeConfig.
2178
+ *
2179
+ * @param {string} pageName - Page name ('lessons' or 'songs')
2180
+ * @returns {string[]} - Array of content type strings
2181
+ *
2182
+ * @example
2183
+ * getAllContentTypesForPage('lessons')
2184
+ * // Returns: ['lesson', 'quick-tips', 'course', 'guided-course', ...]
2185
+ */
2186
+ function getAllContentTypesForPage(pageName) {
2187
+ return filterTypes[pageName] || [];
2188
+ }
2189
+
2190
+ /**
2191
+ * Fetch counts for all content types on a page (lessons/songs) in a single query.
2192
+ * Uses GROQ aggregation to efficiently get counts for multiple content types.
2193
+ * Only returns types with count > 0.
2194
+ *
2195
+ * @param {string} brand - Brand identifier (e.g., 'drumeo', 'playbass')
2196
+ * @param {string} pageName - Page name ('lessons' or 'songs')
2197
+ * @returns {Promise<Object.<string, number>>} - Object mapping content types to counts
2198
+ *
2199
+ * @example
2200
+ * await fetchContentTypeCounts('playbass', 'lessons')
2201
+ * // Returns: { 'guided-course': 45, 'skill-pack': 12, 'special': 8 }
2202
+ */
2203
+ export async function fetchContentTypeCounts(brand, pageName) {
2204
+ const allContentTypes = getAllContentTypesForPage(pageName);
2205
+
2206
+ if (allContentTypes.length === 0) {
2207
+ return {};
2208
+ }
2209
+
2210
+ // Build array of type objects for GROQ query
2211
+ const typesString = allContentTypes
2212
+ .map(type => `{"type": "${type}"}`)
2213
+ .join(', ');
2214
+
2215
+ const query = `{
2216
+ "typeCounts": [${typesString}]{
2217
+ type,
2218
+ 'count': count(*[
2219
+ _type == ^.type
2220
+ && brand == "${brand}"
2221
+ && status == "published"
2222
+ ])
2223
+ }[count > 0]
2224
+ }`;
2225
+
2226
+ const results = await fetchSanity(query, true, { processNeedAccess: false });
2227
+
2228
+ // Convert array to object for easier lookup: { 'guided-course': 45, ... }
2229
+ const countsMap = {};
2230
+ if (results.typeCounts) {
2231
+ results.typeCounts.forEach(item => {
2232
+ countsMap[item.type] = item.count;
2233
+ });
2234
+ }
2235
+
2236
+ return countsMap;
2237
+ }
2238
+
2239
+ /**
2240
+ * Filter tabs based on which content types have content.
2241
+ * Always keeps 'For You' and 'Explore All' tabs.
2242
+ *
2243
+ * @param {Array} tabs - Array of tab objects from metadata
2244
+ * @param {Object.<string, number>} contentTypeCounts - Content type counts
2245
+ * @returns {Array} - Filtered array of tabs with content
2246
+ */
2247
+ function filterTabsByContentCounts(tabs, contentTypeCounts) {
2248
+ return tabs.filter(tab => {
2249
+ if (ALWAYS_VISIBLE_TABS.includes(tab.name)) {
2250
+ return true;
2251
+ }
2252
+
2253
+ const tabContentTypes = TAB_TO_CONTENT_TYPES[tab.name] || [];
2254
+
2255
+ if (tabContentTypes.length === 0) {
2256
+ // Unknown tab - show it to be safe
2257
+ console.warn(`Unknown tab "${tab.name}" - showing by default`);
2258
+ return true;
2259
+ }
2260
+
2261
+ // Tab has content if ANY of its content types have count > 0
2262
+ return tabContentTypes.some(type => contentTypeCounts[type] > 0);
2263
+ });
2264
+ }
2265
+
2266
+ /**
2267
+ * Filter Type filter options based on content type counts.
2268
+ * Removes parent/child options that have no content available.
2269
+ * Returns a new filters array (does not mutate original).
2270
+ *
2271
+ * @param {Array} filters - Filter groups array from metadata
2272
+ * @param {Object.<string, number>} contentTypeCounts - Content type counts
2273
+ * @returns {Array} - Filtered filter groups
2274
+ */
2275
+ function filterTypeOptionsByContentCounts(filters, contentTypeCounts) {
2276
+ return filters.map(filter => {
2277
+ // Only process Type filter
2278
+ if (filter.key !== 'type') {
2279
+ return filter;
2280
+ }
2281
+
2282
+ const filteredItems = filter.items.map(item => {
2283
+ // For hierarchical filters (parent with children)
2284
+ if (item.isParent && item.items) {
2285
+ // Filter children based on their content types
2286
+ const availableChildren = item.items.filter(child => {
2287
+ const childTypes = getContentTypesForFilterName(child.name);
2288
+
2289
+ if (!childTypes || childTypes.length === 0) {
2290
+ console.warn(`Unknown filter child "${child.name}" - showing by default`);
2291
+ return true;
2292
+ }
2293
+
2294
+ // Child has content if ANY of its types have count > 0
2295
+ return childTypes.some(type => contentTypeCounts[type] > 0);
2296
+ });
2297
+
2298
+ // Keep parent only if it has available children
2299
+ if (availableChildren.length > 0) {
2300
+ // Return NEW object to avoid mutation
2301
+ return { ...item, items: availableChildren };
2302
+ }
2303
+ return null;
2304
+ }
2305
+
2306
+ // For flat items (no children)
2307
+ const itemTypes = getContentTypesForFilterName(item.name);
2308
+
2309
+ if (!itemTypes || itemTypes.length === 0) {
2310
+ console.warn(`Unknown filter item "${item.name}" - showing by default`);
2311
+ return item;
2312
+ }
2313
+
2314
+ // Item has content if ANY of its types have count > 0
2315
+ const hasContent = itemTypes.some(type => contentTypeCounts[type] > 0);
2316
+ return hasContent ? item : null;
2317
+ }).filter(Boolean); // Remove nulls
2318
+
2319
+ // Return new filter object with filtered items
2320
+ return {
2321
+ ...filter,
2322
+ items: filteredItems
2323
+ };
2324
+ }).filter(filter => {
2325
+ if (filter.key === 'type' && filter.items.length === 0) {
2326
+ return false;
2327
+ }
2328
+ return true;
2329
+ });
2330
+ }
2331
+
2332
+ /**
2333
+ * Maps a display name to its corresponding content types from lessonTypesMapping.
2334
+ * @param {string} displayName - The display name from filter metadata
2335
+ * @returns {string[]|undefined} - Array of content types or undefined if not found
2336
+ */
2337
+ function getContentTypesForFilterName(displayName) {
2338
+ const displayNameToKey = {
2339
+ 'Lessons': 'lessons',
2340
+ 'Practice Alongs': 'practice alongs',
2341
+ 'Live Archives': 'live archives',
2342
+ 'Student Archives': 'student archives',
2343
+ 'Courses': 'courses',
2344
+ 'Guided Courses': 'guided courses',
2345
+ 'Tiered Courses': 'tiered courses',
2346
+ 'Specials': 'specials',
2347
+ 'Documentaries': 'documentaries',
2348
+ 'Shows': 'shows',
2349
+ 'Skill Packs': 'skill packs',
2350
+ 'Tutorials': 'tutorials',
2351
+ 'Transcriptions': 'transcriptions',
2352
+ 'Sheet Music': 'sheet music',
2353
+ 'Tabs': 'tabs',
2354
+ 'Play-Alongs': 'play-alongs',
2355
+ 'Jam Tracks': 'jam tracks',
2356
+ };
2357
+
2358
+ const mappingKey = displayNameToKey[displayName];
2359
+ return mappingKey ? lessonTypesMapping[mappingKey] : undefined;
2360
+ }
@@ -3,6 +3,8 @@ import schema from '../schema'
3
3
  import type SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'
4
4
  import type LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
5
5
 
6
+ import { SyncTelemetry } from '../telemetry'
7
+
6
8
  export type DatabaseAdapter = SQLiteAdapter | LokiJSAdapter
7
9
 
8
10
  type SQLiteAdapterOptions = ConstructorParameters<typeof SQLiteAdapter>[0]
@@ -12,15 +14,13 @@ type DatabaseAdapterOptions = SQLiteAdapterOptions & LokiJSAdapterOptions
12
14
 
13
15
  export default function syncAdapterFactory<T extends DatabaseAdapter>(
14
16
  AdapterClass: new (options: DatabaseAdapterOptions) => T,
15
- namespace: string,
17
+ _namespace: string,
16
18
  opts: Omit<DatabaseAdapterOptions, 'schema' | 'migrations'>
17
19
  ): () => T {
18
- const options = {
20
+ return () => new AdapterClass({
19
21
  ...opts,
20
- dbName: `sync:${namespace}`,
22
+ dbName: `sync`, // don't use user namespace for now
21
23
  schema,
22
24
  migrations: undefined
23
- }
24
-
25
- return () => new AdapterClass(options)
25
+ })
26
26
  }
@@ -1 +1,174 @@
1
- export { default } from '@nozbe/watermelondb/adapters/lokijs'
1
+ import { SyncTelemetry } from '../telemetry'
2
+
3
+ import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
4
+ export { LokiJSAdapter as default }
5
+
6
+ import { deleteDatabase } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
7
+
8
+ /**
9
+ * Mute impending driver errors that are expected after sync adapter failure
10
+ */
11
+ export function muteImpendingDriverErrors() {
12
+ SyncTelemetry.getInstance()?.ignoreLike(
13
+ 'Cannot run actions because driver is not set up',
14
+ /The reader you're trying to run \(.+\) can't be performed yet/
15
+ )
16
+ }
17
+
18
+ /**
19
+ * Patch IndexedDB open to listen for specifically definitely asynchronous errors
20
+ */
21
+ export function patchIndexedDBOpenErrors(
22
+ onAnyError: (error: Error | DOMException | null, event: Event) => void
23
+ ) {
24
+ const idb = window.indexedDB as any
25
+ const originalOpen = idb.open
26
+
27
+ idb.open = function(...args: any[]) {
28
+ let onerror: ((ev: Event) => any) | undefined
29
+
30
+ const realRequest = originalOpen.apply(this, args)
31
+ const proxyRequest = new Proxy(realRequest, {
32
+ get(target, prop) {
33
+ if (prop === 'onerror') return onerror
34
+ return target[prop]
35
+ },
36
+ set(target, prop, value) {
37
+ if (prop === 'onerror') {
38
+ onerror = value
39
+ return true
40
+ }
41
+ target[prop] = value
42
+ return true
43
+ },
44
+ })
45
+
46
+ realRequest.onerror = event => {
47
+ onAnyError((event.target as any).error, event)
48
+ onerror?.call(proxyRequest, event)
49
+ }
50
+
51
+ return proxyRequest
52
+ }
53
+ }
54
+
55
+ export function simulateIndexedDBDelay(minDelayMs = 2000) {
56
+ patchIndexedDBOpen({ delay: minDelayMs })
57
+ }
58
+ export function simulateIndexedDBUnavailable() {
59
+ patchIndexedDBOpen({
60
+ forceError: () => new DOMException('Simulated IndexedDB unavailable error', 'UnknownError'),
61
+ })
62
+ }
63
+
64
+ export function simulateIndexedDBFailure(minDelayMs = 2000) {
65
+ patchIndexedDBOpen({
66
+ delay: minDelayMs,
67
+ forceError: () => new DOMException('Simulated IndexedDB failure error', 'UnknownError')
68
+ })
69
+ }
70
+
71
+ export function simulateIndexedDBQuotaExceeded() {
72
+ patchIndexedDBOpen({
73
+ delay: 0,
74
+ forceError: () => new DOMException('Simulated quota exceeded', 'QuotaExceededError')
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Completely destroy database, as opposed to watermelon's reset
80
+ * (which merely clears all records but re-initializes the database schema)
81
+ * Haven't encountered live issues related to this yet, but theoretically provides
82
+ * the cleanest slate for a user to recover from schema issues?
83
+ */
84
+ export function destroyDatabase(dbName: string, adapter: LokiJSAdapter): Promise<void> {
85
+ return new Promise(async (resolve, reject) => {
86
+ if (adapter._driver) {
87
+ // try {
88
+ // // good manners to clear the cache, even though this adapter will likely be discarded
89
+ // adapter._clearCachedRecords();
90
+ // } catch (err: unknown) {
91
+ // SyncTelemetry.getInstance()?.capture(err)
92
+ // }
93
+
94
+ try {
95
+ await deleteDatabase(adapter._driver.loki)
96
+ return resolve();
97
+ } catch (err: unknown) {
98
+ SyncTelemetry.getInstance()?.capture(err as Error)
99
+ return reject(err);
100
+ }
101
+ }
102
+
103
+ destroyIndexedDBDatabase(dbName).then(() => resolve()).catch((err) => reject(err));
104
+ });
105
+ }
106
+
107
+ function patchIndexedDBOpen({ delay, forceError }: { delay?: number, forceError?: () => void }) {
108
+ const idb = window.indexedDB as any
109
+ const originalOpen = idb.open
110
+
111
+ const startTime = Date.now()
112
+
113
+ // Wrap open function and proxy success and error handlers
114
+ idb.open = function(...args: any[]) {
115
+ if (forceError && typeof delay === 'undefined') {
116
+ throw forceError()
117
+ }
118
+
119
+ let onsuccess: ((ev: Event) => any) | undefined
120
+ let onerror: ((ev: Event) => any) | undefined
121
+
122
+ const realRequest = originalOpen.apply(this, args)
123
+ const proxyRequest = new Proxy(realRequest, {
124
+ get(target, prop) {
125
+ if (prop === 'onsuccess') return onsuccess
126
+ if (prop === 'onerror') return onerror
127
+ return target[prop]
128
+ },
129
+ set(target, prop, value) {
130
+ if (prop === 'onsuccess') {
131
+ onsuccess = value
132
+ return true
133
+ }
134
+ if (prop === 'onerror') {
135
+ onerror = value
136
+ return true
137
+ }
138
+ target[prop] = value
139
+ return true
140
+ },
141
+ })
142
+
143
+ // trigger success after provided delay, only if no error is forced
144
+ realRequest.onsuccess = event => {
145
+ if (!forceError) {
146
+ setTimeout(() => onsuccess?.call(proxyRequest, event), Math.max(0, delay - (Date.now() - startTime)))
147
+ }
148
+ }
149
+
150
+ // force error after provided delay
151
+ if (forceError) {
152
+ setTimeout(() => {
153
+ const error = forceError()
154
+ const event = new Event('error')
155
+ Object.defineProperty(event, 'target', { value: { error } })
156
+ onerror?.call(proxyRequest, event)
157
+ }, Math.max(0, delay - (Date.now() - startTime)))
158
+ }
159
+
160
+ return proxyRequest
161
+ }
162
+ }
163
+
164
+ function destroyIndexedDBDatabase(dbName: string) {
165
+ return new Promise<void>((resolve, reject) => {
166
+ const request = indexedDB.deleteDatabase(dbName);
167
+
168
+ request.onsuccess = () => resolve();
169
+ request.onblocked = () => {
170
+ reject(new Error(`IndexedDB deletion blocked for "${dbName}"`));
171
+ };
172
+ request.onerror = () => reject(request.error ?? new Error("Manual IndexedDB deletion failed"));
173
+ })
174
+ }