musora-content-services 2.108.0 → 2.111.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +29 -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 +11 -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/railcontent.js +0 -163
  11. package/src/services/sanity.js +258 -7
  12. package/src/services/sync/adapters/factory.ts +6 -6
  13. package/src/services/sync/adapters/lokijs.ts +174 -1
  14. package/src/services/sync/context/providers/durability.ts +1 -0
  15. package/src/services/sync/database/factory.ts +12 -5
  16. package/src/services/sync/effects/index.ts +6 -0
  17. package/src/services/sync/effects/logout-warning.ts +47 -0
  18. package/src/services/sync/errors/boundary.ts +4 -6
  19. package/src/services/sync/errors/index.ts +16 -0
  20. package/src/services/sync/fetch.ts +5 -5
  21. package/src/services/sync/manager.ts +80 -40
  22. package/src/services/sync/repositories/base.ts +1 -8
  23. package/src/services/sync/retry.ts +4 -4
  24. package/src/services/sync/store/index.ts +34 -31
  25. package/src/services/sync/store/push-coalescer.ts +3 -3
  26. package/src/services/sync/store-configs.ts +10 -8
  27. package/src/services/sync/telemetry/flood-prevention.ts +27 -0
  28. package/src/services/sync/telemetry/index.ts +71 -9
  29. package/src/services/sync/telemetry/sampling.ts +2 -6
  30. package/src/services/user/types.d.ts +0 -7
  31. package/test/sync/adapter.ts +2 -34
  32. package/test/sync/initialize-sync-manager.js +8 -25
  33. package/.claude/settings.local.json +0 -9
  34. package/src/services/sync/concurrency-safety.ts +0 -4
@@ -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
+ }
@@ -2,4 +2,5 @@ import BaseContextProvider from "./base";
2
2
 
3
3
  export default abstract class BaseDurabilityProvider extends BaseContextProvider {
4
4
  abstract getValue(): boolean
5
+ abstract failed(): void
5
6
  }
@@ -2,9 +2,16 @@ import type { DatabaseAdapter } from '../adapters/factory'
2
2
  import { Database, } from '@nozbe/watermelondb'
3
3
  import * as modelClasses from '../models'
4
4
 
5
- export default function syncDatabaseFactory(adapter: () => DatabaseAdapter) {
6
- return () => new Database({
7
- adapter: adapter(),
8
- modelClasses: Object.values(modelClasses)
9
- })
5
+ export default function syncDatabaseFactory(adapter: () => DatabaseAdapter, { onInitError }: { onInitError?: (error: Error) => void } = {}) {
6
+ return () => {
7
+ try {
8
+ return new Database({
9
+ adapter: adapter(),
10
+ modelClasses: Object.values(modelClasses)
11
+ })
12
+ } catch (error) {
13
+ onInitError?.(error as Error)
14
+ throw error
15
+ }
16
+ }
10
17
  }
@@ -0,0 +1,6 @@
1
+ import type SyncContext from "../context"
2
+ import type SyncStore from "../store"
3
+
4
+ export type SyncEffect = (context: SyncContext, stores: SyncStore[]) => () => void
5
+
6
+ export { default as createLogoutWarningEffect } from './logout-warning'
@@ -0,0 +1,47 @@
1
+ import { Subscription } from 'rxjs'
2
+ import { Q } from '@nozbe/watermelondb'
3
+
4
+ import { type SyncEffect } from '.'
5
+
6
+ import { type ModelClass } from '../index'
7
+
8
+ // notifies a subscriber that unsynced records exist
9
+ // ideally used by a logout interrupt prompt to tell the user that logging out
10
+ // now would make them lose data
11
+
12
+ // we notify eagerly so that the prompt can be shown as soon as user clicks logout,
13
+ // instead of waiting for a lazy query at that moment
14
+
15
+ const createLogoutWarningEffect = (notifyCallback: (unsyncedModels: ModelClass[]) => void) => {
16
+ const logoutWarning: SyncEffect = function (context, stores) {
17
+ const unsyncedModels = new Set<ModelClass>()
18
+ const subscriptions: Subscription[] = []
19
+
20
+ const notifyFromAll = () => {
21
+ notifyCallback(Array.from(unsyncedModels))
22
+ }
23
+
24
+ stores.forEach((store) => {
25
+ const sub = store.collection
26
+ .query(Q.where('_status', Q.notEq('synced')), Q.take(1)) // todo - doesn't consider deleted records ??
27
+ .observe()
28
+ .subscribe((records) => {
29
+ if (records.length > 0) {
30
+ unsyncedModels.add(store.model)
31
+ } else {
32
+ unsyncedModels.delete(store.model)
33
+ }
34
+ notifyFromAll()
35
+ })
36
+ subscriptions.push(sub)
37
+ })
38
+
39
+ return () => {
40
+ subscriptions.forEach((sub) => sub.unsubscribe())
41
+ }
42
+ }
43
+
44
+ return logoutWarning
45
+ }
46
+
47
+ export default createLogoutWarningEffect
@@ -28,18 +28,16 @@ export function inBoundary<T, TContext extends Record<string, any>>(fn: (context
28
28
 
29
29
  if (result instanceof Promise) {
30
30
  return result.catch((err: unknown) => {
31
- const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
32
- SyncTelemetry.getInstance()?.capture(wrapped)
31
+ SyncTelemetry.getInstance()?.capture(err as Error, context)
33
32
 
34
- throw wrapped;
33
+ throw err;
35
34
  });
36
35
  }
37
36
 
38
37
  return result;
39
38
  } catch (err: unknown) {
40
- const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
41
- SyncTelemetry.getInstance()?.capture(wrapped);
39
+ SyncTelemetry.getInstance()?.capture(err as Error, context);
42
40
 
43
- throw wrapped;
41
+ throw err;
44
42
  }
45
43
  }
@@ -38,6 +38,22 @@ export class SyncStoreError extends SyncError {
38
38
  }
39
39
  }
40
40
 
41
+ export class SyncInitError extends SyncError {
42
+ constructor(error: unknown) {
43
+ super('initError', { cause: error })
44
+ this.name = 'SyncInitError'
45
+ Object.setPrototypeOf(this, new.target.prototype)
46
+ }
47
+ }
48
+
49
+ export class SyncSetupError extends SyncError {
50
+ constructor(error: unknown) {
51
+ super('setupError', { cause: error })
52
+ this.name = 'SyncSetupError'
53
+ Object.setPrototypeOf(this, new.target.prototype)
54
+ }
55
+ }
56
+
41
57
  // useful for transforming non-sync-related errors into one
42
58
  // that captures surrounding details (e.g., table name)
43
59
  export class SyncUnexpectedError extends SyncError {
@@ -34,18 +34,18 @@ type SyncPushFetchFailureResponse = SyncResponseBase & {
34
34
  failureType: 'fetch'
35
35
  isRetryable: boolean
36
36
  }
37
- export type SyncPushFailureResponse = SyncResponseBase & {
37
+ type SyncPushFailureResponse = SyncResponseBase & {
38
38
  ok: false,
39
39
  failureType: 'error'
40
40
  originalError: Error
41
41
  }
42
42
 
43
- type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
44
- type SyncStorePushResultSuccess<TRecordKey extends string = 'id'> = SyncStorePushResultBase & {
43
+ export type SyncStorePushResult<TRecordKey extends string = 'id'> = SyncStorePushResultSuccess<TRecordKey> | SyncStorePushResultFailure<TRecordKey>
44
+ export type SyncStorePushResultSuccess<TRecordKey extends string = 'id'> = SyncStorePushResultBase & {
45
45
  type: 'success'
46
46
  entry: SyncEntry<BaseModel, TRecordKey>
47
47
  }
48
- type SyncStorePushResultFailure<TRecordKey extends string = 'id'> = SyncStorePushResultProcessingFailure<TRecordKey> | SyncStorePushResultValidationFailure<TRecordKey>
48
+ export type SyncStorePushResultFailure<TRecordKey extends string = 'id'> = SyncStorePushResultProcessingFailure<TRecordKey> | SyncStorePushResultValidationFailure<TRecordKey>
49
49
  type SyncStorePushResultProcessingFailure<TRecordKey extends string = 'id'> = SyncStorePushResultFailureBase<TRecordKey> & {
50
50
  failureType: 'processing'
51
51
  error: any
@@ -71,7 +71,7 @@ type SyncPullSuccessResponse = SyncResponseBase & {
71
71
  token: SyncToken
72
72
  previousToken: SyncToken | null
73
73
  }
74
- type SyncPullFetchFailureResponse = SyncResponseBase & {
74
+ export type SyncPullFetchFailureResponse = SyncResponseBase & {
75
75
  ok: false,
76
76
  failureType: 'fetch'
77
77
  isRetryable: boolean