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.
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/contentMetaData.js +0 -5
- package/src/contentTypeConfig.js +11 -73
- package/src/index.d.ts +0 -20
- package/src/index.js +0 -20
- package/src/services/content.js +1 -1
- package/src/services/contentAggregator.js +1 -4
- package/src/services/railcontent.js +0 -163
- package/src/services/sanity.js +258 -7
- package/src/services/sync/adapters/factory.ts +6 -6
- package/src/services/sync/adapters/lokijs.ts +174 -1
- package/src/services/sync/context/providers/durability.ts +1 -0
- package/src/services/sync/database/factory.ts +12 -5
- package/src/services/sync/effects/index.ts +6 -0
- package/src/services/sync/effects/logout-warning.ts +47 -0
- package/src/services/sync/errors/boundary.ts +4 -6
- package/src/services/sync/errors/index.ts +16 -0
- package/src/services/sync/fetch.ts +5 -5
- package/src/services/sync/manager.ts +80 -40
- package/src/services/sync/repositories/base.ts +1 -8
- package/src/services/sync/retry.ts +4 -4
- package/src/services/sync/store/index.ts +34 -31
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +10 -8
- package/src/services/sync/telemetry/flood-prevention.ts +27 -0
- package/src/services/sync/telemetry/index.ts +71 -9
- package/src/services/sync/telemetry/sampling.ts +2 -6
- package/src/services/user/types.d.ts +0 -7
- package/test/sync/adapter.ts +2 -34
- package/test/sync/initialize-sync-manager.js +8 -25
- package/.claude/settings.local.json +0 -9
- package/src/services/sync/concurrency-safety.ts +0 -4
package/src/services/sanity.js
CHANGED
|
@@ -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
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
_namespace: string,
|
|
16
18
|
opts: Omit<DatabaseAdapterOptions, 'schema' | 'migrations'>
|
|
17
19
|
): () => T {
|
|
18
|
-
|
|
20
|
+
return () => new AdapterClass({
|
|
19
21
|
...opts,
|
|
20
|
-
dbName: `sync
|
|
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
|
-
|
|
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,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 () =>
|
|
7
|
-
|
|
8
|
-
|
|
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,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
|
-
|
|
32
|
-
SyncTelemetry.getInstance()?.capture(wrapped)
|
|
31
|
+
SyncTelemetry.getInstance()?.capture(err as Error, context)
|
|
33
32
|
|
|
34
|
-
throw
|
|
33
|
+
throw err;
|
|
35
34
|
});
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
return result;
|
|
39
38
|
} catch (err: unknown) {
|
|
40
|
-
|
|
41
|
-
SyncTelemetry.getInstance()?.capture(wrapped);
|
|
39
|
+
SyncTelemetry.getInstance()?.capture(err as Error, context);
|
|
42
40
|
|
|
43
|
-
throw
|
|
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
|
-
|
|
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
|