diff --git a/src/components/dialog/content/manager/ManagerDialogContent.vue b/src/components/dialog/content/manager/ManagerDialogContent.vue
index f255a771c..34f500243 100644
--- a/src/components/dialog/content/manager/ManagerDialogContent.vue
+++ b/src/components/dialog/content/manager/ManagerDialogContent.vue
@@ -32,6 +32,7 @@
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
+ :sort-options="sortOptions"
/>
@@ -56,21 +56,26 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
-import type { NodesIndexSuggestion } from '@/types/algoliaTypes'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
+import type {
+ QuerySuggestion,
+ SearchMode,
+ SortableField
+} from '@/types/searchServiceTypes'
-const { searchResults } = defineProps<{
+const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
- suggestions?: NodesIndexSuggestion[]
+ suggestions?: QuerySuggestion[]
+ sortOptions?: SortableField[]
}>()
const searchQuery = defineModel('searchQuery')
-const searchMode = defineModel('searchMode', { default: 'packs' })
-const sortField = defineModel('sortField', {
+const searchMode = defineModel('searchMode', { default: 'packs' })
+const sortField = defineModel('sortField', {
default: SortableAlgoliaField.Downloads
})
@@ -80,18 +85,19 @@ const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
-const sortOptions: SearchOption[] = [
- { id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
- { id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
- { id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
- { id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
- { id: SortableAlgoliaField.Name, label: t('g.name') }
-]
-const filterOptions: SearchOption[] = [
+const availableSortOptions = computed[]>(() => {
+ if (!sortOptions) return []
+ return sortOptions.map((field) => ({
+ id: field.id,
+ label: field.label
+ }))
+})
+const filterOptions: SearchOption[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
+// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}
diff --git a/src/composables/useRegistrySearch.ts b/src/composables/useRegistrySearch.ts
index 6e943d88a..001894b27 100644
--- a/src/composables/useRegistrySearch.ts
+++ b/src/composables/useRegistrySearch.ts
@@ -1,95 +1,61 @@
import { watchDebounced } from '@vueuse/core'
-import type { Hit } from 'algoliasearch/dist/lite/browser'
-import { memoize, orderBy } from 'lodash'
+import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
-import { useAlgoliaSearchService } from '@/services/algoliaSearchService'
-import type {
- AlgoliaNodePack,
- NodesIndexSuggestion,
- SearchAttribute
-} from '@/types/algoliaTypes'
+import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
+import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
+import type { SearchAttribute } from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
+import type { components } from '@/types/comfyRegistryTypes'
+import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
+
+type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
-const DEFAULT_PAGE_SIZE = 64
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
-const SORT_DIRECTIONS: Record = {
- [SortableAlgoliaField.Downloads]: 'desc',
- [SortableAlgoliaField.Created]: 'desc',
- [SortableAlgoliaField.Updated]: 'desc',
- [SortableAlgoliaField.Publisher]: 'asc',
- [SortableAlgoliaField.Name]: 'asc'
-}
-
-const isDateField = (field: SortableAlgoliaField): boolean =>
- field === SortableAlgoliaField.Created ||
- field === SortableAlgoliaField.Updated
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
-export function useRegistrySearch(options: {
- initialSortField?: SortableAlgoliaField
- initialSearchMode?: 'nodes' | 'packs'
- initialSearchQuery?: string
- initialPageNumber?: number
-}) {
+export function useRegistrySearch(
+ options: {
+ initialSortField?: string
+ initialSearchMode?: SearchMode
+ initialSearchQuery?: string
+ initialPageNumber?: number
+ } = {}
+) {
const {
- initialSortField = SortableAlgoliaField.Downloads,
+ initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const isLoading = ref(false)
- const sortField = ref(initialSortField)
- const searchMode = ref<'nodes' | 'packs'>(initialSearchMode)
+ const sortField = ref(initialSortField)
+ const searchMode = ref(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
- const results = ref([])
- const suggestions = ref([])
+ const searchResults = ref([])
+ const suggestions = ref([])
const searchAttributes = computed(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
- const resultsAsRegistryPacks = computed(() =>
- results.value ? results.value.map(algoliaToRegistry) : []
- )
- const resultsAsNodes = computed(() =>
- results.value
- ? results.value.reduce(
- (acc, hit) => acc.concat(hit.comfy_nodes),
- [] as string[]
- )
- : []
- )
+ const searchGateway = useRegistrySearchGateway()
- const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
- useAlgoliaSearchService()
-
- const algoliaToRegistry = memoize(
- toRegistryPack,
- (algoliaNode: AlgoliaNodePack) => algoliaNode.id
- )
- const getSortValue = (pack: Hit) => {
- if (isDateField(sortField.value)) {
- const value = pack[sortField.value]
- return value ? new Date(value).getTime() : 0
- } else {
- const value = pack[sortField.value]
- return value ?? 0
- }
- }
+ const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
+ searchGateway
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
- const { nodePacks, querySuggestions } = await searchPacksCached(
+ const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
@@ -102,17 +68,22 @@ export function useRegistrySearch(options: {
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
+ // Get the sort direction from the provider's sortable fields
+ const sortableFields = getSortableFields()
+ const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
+ const direction = fieldConfig?.direction || 'desc'
+
sortedPacks = orderBy(
nodePacks,
- [getSortValue],
- [SORT_DIRECTIONS[sortField.value]]
+ [(pack) => getSortValue(pack, sortField.value)],
+ [direction]
)
}
- if (options.append && results.value?.length) {
- results.value = results.value.concat(sortedPacks)
+ if (options.append && searchResults.value?.length) {
+ searchResults.value = searchResults.value.concat(sortedPacks)
} else {
- results.value = sortedPacks
+ searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -128,6 +99,10 @@ export function useRegistrySearch(options: {
immediate: true
})
+ const sortOptions = computed(() => {
+ return getSortableFields()
+ })
+
return {
isLoading,
pageNumber,
@@ -136,8 +111,8 @@ export function useRegistrySearch(options: {
searchMode,
searchQuery,
suggestions,
- searchResults: resultsAsRegistryPacks,
- nodeSearchResults: resultsAsNodes,
- clearCache: clearSearchPacksCache
+ searchResults,
+ sortOptions,
+ clearCache: clearSearchCache
}
}
diff --git a/src/constants/searchConstants.ts b/src/constants/searchConstants.ts
new file mode 100644
index 000000000..c65377574
--- /dev/null
+++ b/src/constants/searchConstants.ts
@@ -0,0 +1,3 @@
+export const SEARCH_CACHE_MAX_SIZE = 64
+export const DEFAULT_PAGE_SIZE = 64
+export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2
diff --git a/src/services/algoliaSearchService.ts b/src/services/algoliaSearchService.ts
deleted file mode 100644
index 9dd57d9df..000000000
--- a/src/services/algoliaSearchService.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import QuickLRU from '@alloc/quick-lru'
-import type {
- SearchQuery,
- SearchResponse
-} from 'algoliasearch/dist/lite/browser'
-import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
-import { omit } from 'lodash'
-
-import type {
- AlgoliaNodePack,
- NodesIndexSuggestion,
- SearchAttribute,
- SearchNodePacksParams,
- SearchPacksResult
-} from '@/types/algoliaTypes'
-import type { components } from '@/types/comfyRegistryTypes'
-import { paramsToCacheKey } from '@/utils/formatUtil'
-
-type RegistryNodePack = components['schemas']['Node']
-
-const DEFAULT_MAX_CACHE_SIZE = 64
-const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
-
-const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
- 'comfy_nodes',
- 'name',
- 'description',
- 'latest_version',
- 'status',
- 'publisher_id',
- 'total_install',
- 'create_time',
- 'update_time',
- 'license',
- 'repository_url',
- 'latest_version_status',
- 'comfy_node_extract_status',
- 'id',
- 'icon_url'
-]
-
-interface AlgoliaSearchServiceOptions {
- /**
- * Minimum number of characters for suggestions. An additional query
- * will be made to the suggestions/completions index for queries that
- * are this length or longer.
- * @default 3
- */
- minCharsForSuggestions?: number
-}
-
-const searchPacksCache = new QuickLRU({
- maxSize: DEFAULT_MAX_CACHE_SIZE
-})
-
-export const useAlgoliaSearchService = (
- options: AlgoliaSearchServiceOptions = {}
-) => {
- const { minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS } = options
- const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
-
- const toRegistryLatestVersion = (
- algoliaNode: AlgoliaNodePack
- ): RegistryNodePack['latest_version'] => {
- return {
- version: algoliaNode.latest_version,
- createdAt: algoliaNode.update_time,
- status: algoliaNode.latest_version_status,
- comfy_node_extract_status:
- algoliaNode.comfy_node_extract_status ?? undefined
- }
- }
-
- const toRegistryPublisher = (
- algoliaNode: AlgoliaNodePack
- ): RegistryNodePack['publisher'] => {
- return {
- id: algoliaNode.publisher_id,
- name: algoliaNode.publisher_id
- }
- }
-
- /**
- * Convert from node pack in Algolia format to Comfy Registry format
- */
- function toRegistryPack(algoliaNode: AlgoliaNodePack): RegistryNodePack {
- return {
- id: algoliaNode.id ?? algoliaNode.objectID,
- name: algoliaNode.name,
- description: algoliaNode.description,
- repository: algoliaNode.repository_url,
- license: algoliaNode.license,
- downloads: algoliaNode.total_install,
- status: algoliaNode.status,
- icon: algoliaNode.icon_url,
- latest_version: toRegistryLatestVersion(algoliaNode),
- publisher: toRegistryPublisher(algoliaNode),
- // @ts-expect-error remove when comfy_nodes is added to node (pack) info
- comfy_nodes: algoliaNode.comfy_nodes
- }
- }
-
- /**
- * Search for node packs in Algolia
- */
- const searchPacks = async (
- query: string,
- params: SearchNodePacksParams
- ): Promise => {
- const { pageSize, pageNumber } = params
- const rest = omit(params, ['pageSize', 'pageNumber'])
-
- const requests: SearchQuery[] = [
- {
- query,
- indexName: 'nodes_index',
- attributesToRetrieve: RETRIEVE_ATTRIBUTES,
- ...rest,
- hitsPerPage: pageSize,
- page: pageNumber
- }
- ]
-
- const shouldQuerySuggestions = query.length >= minCharsForSuggestions
-
- // If the query is long enough, also query the suggestions index
- if (shouldQuerySuggestions) {
- requests.push({
- indexName: 'nodes_index_query_suggestions',
- query
- })
- }
-
- const { results } = await searchClient.search<
- AlgoliaNodePack | NodesIndexSuggestion
- >({
- requests,
- strategy: 'none'
- })
-
- const [nodePacks, querySuggestions = { hits: [] }] = results as [
- SearchResponse,
- SearchResponse
- ]
-
- return {
- nodePacks: nodePacks.hits,
- querySuggestions: querySuggestions.hits
- }
- }
-
- const searchPacksCached = async (
- query: string,
- params: SearchNodePacksParams
- ): Promise => {
- const cacheKey = paramsToCacheKey({ query, ...params })
- const cachedResult = searchPacksCache.get(cacheKey)
- if (cachedResult !== undefined) return cachedResult
-
- const result = await searchPacks(query, params)
- searchPacksCache.set(cacheKey, result)
- return result
- }
-
- const clearSearchPacksCache = () => {
- searchPacksCache.clear()
- }
-
- return {
- searchPacks,
- searchPacksCached,
- toRegistryPack,
- clearSearchPacksCache
- }
-}
diff --git a/src/services/gateway/registrySearchGateway.ts b/src/services/gateway/registrySearchGateway.ts
new file mode 100644
index 000000000..834e730a8
--- /dev/null
+++ b/src/services/gateway/registrySearchGateway.ts
@@ -0,0 +1,227 @@
+import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
+import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
+import type { SearchNodePacksParams } from '@/types/algoliaTypes'
+import type { components } from '@/types/comfyRegistryTypes'
+import type {
+ NodePackSearchProvider,
+ SearchPacksResult
+} from '@/types/searchServiceTypes'
+
+type RegistryNodePack = components['schemas']['Node']
+
+interface ProviderState {
+ provider: NodePackSearchProvider
+ name: string
+ isHealthy: boolean
+ lastError?: Error
+ lastAttempt?: Date
+ consecutiveFailures: number
+}
+
+const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
+const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
+
+/**
+ * API Gateway for registry search providers with circuit breaker pattern.
+ * Acts as a single entry point that routes search requests to appropriate providers
+ * and handles failures gracefully by falling back to alternative providers.
+ *
+ * Implements:
+ * - Gateway pattern: Single entry point for all search requests
+ * - Circuit breaker: Prevents repeated calls to failed services
+ * - Automatic failover: Cascades through providers on failure
+ */
+export const useRegistrySearchGateway = (): NodePackSearchProvider => {
+ const providers: ProviderState[] = []
+ let activeProviderIndex = 0
+
+ // Initialize providers in priority order
+ try {
+ providers.push({
+ provider: useAlgoliaSearchProvider(),
+ name: 'Algolia',
+ isHealthy: true,
+ consecutiveFailures: 0
+ })
+ } catch (error) {
+ console.warn('Failed to initialize Algolia provider:', error)
+ }
+
+ providers.push({
+ provider: useComfyRegistrySearchProvider(),
+ name: 'ComfyRegistry',
+ isHealthy: true,
+ consecutiveFailures: 0
+ })
+
+ // TODO: Add an "offline" provider that operates on a local cache of the registry.
+
+ /**
+ * Check if a provider's circuit breaker should be closed (available to try)
+ */
+ const isCircuitClosed = (providerState: ProviderState): boolean => {
+ if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
+ return true
+ }
+
+ // Check if enough time has passed to retry
+ if (providerState.lastAttempt) {
+ const timeSinceLastAttempt =
+ Date.now() - providerState.lastAttempt.getTime()
+ if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
+ console.info(
+ `Retrying ${providerState.name} provider after circuit breaker timeout`
+ )
+ return true
+ }
+ }
+
+ return false
+ }
+
+ /**
+ * Record a successful call to a provider
+ */
+ const recordSuccess = (providerState: ProviderState) => {
+ providerState.isHealthy = true
+ providerState.consecutiveFailures = 0
+ providerState.lastError = undefined
+ }
+
+ /**
+ * Record a failed call to a provider
+ */
+ const recordFailure = (providerState: ProviderState, error: Error) => {
+ providerState.consecutiveFailures++
+ providerState.lastError = error
+ providerState.lastAttempt = new Date()
+
+ if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
+ providerState.isHealthy = false
+ console.warn(
+ `${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
+ )
+ }
+ }
+
+ /**
+ * Get the currently active provider based on circuit breaker states
+ */
+ const getActiveProvider = (): NodePackSearchProvider => {
+ // First, try to use the current active provider if it's healthy
+ const currentProvider = providers[activeProviderIndex]
+ if (currentProvider && isCircuitClosed(currentProvider)) {
+ return currentProvider.provider
+ }
+
+ // Otherwise, find the first healthy provider
+ for (let i = 0; i < providers.length; i++) {
+ const providerState = providers[i]
+ if (isCircuitClosed(providerState)) {
+ activeProviderIndex = i
+ return providerState.provider
+ }
+ }
+
+ throw new Error('No available search providers')
+ }
+
+ /**
+ * Update the active provider index after a failure.
+ * Move to the next provider if available.
+ */
+ const updateActiveProviderOnFailure = () => {
+ if (activeProviderIndex < providers.length - 1) {
+ activeProviderIndex++
+ }
+ }
+
+ /**
+ * Search for node packs.
+ */
+ const searchPacks = async (
+ query: string,
+ params: SearchNodePacksParams
+ ): Promise => {
+ let lastError: Error | null = null
+
+ // Start with the current active provider
+ for (let attempts = 0; attempts < providers.length; attempts++) {
+ try {
+ const provider = getActiveProvider()
+ const providerState = providers[activeProviderIndex]
+
+ const result = await provider.searchPacks(query, params)
+ recordSuccess(providerState)
+ return result
+ } catch (error) {
+ lastError = error as Error
+ const providerState = providers[activeProviderIndex]
+ recordFailure(providerState, lastError)
+ console.warn(
+ `${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
+ error
+ )
+
+ // Try the next provider
+ updateActiveProviderOnFailure()
+ }
+ }
+
+ // If we get here, all providers failed
+ throw new Error(
+ `All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
+ )
+ }
+
+ /**
+ * Clear the search cache for all providers that implement it.
+ */
+ const clearSearchCache = () => {
+ for (const providerState of providers) {
+ try {
+ providerState.provider.clearSearchCache()
+ } catch (error) {
+ console.warn(
+ `Failed to clear cache for ${providerState.name} provider:`,
+ error
+ )
+ }
+ }
+ }
+
+ /**
+ * Get the sort value for a pack.
+ * @example
+ * const pack = {
+ * id: '123',
+ * name: 'Test Pack',
+ * downloads: 100
+ * }
+ * const sortValue = getSortValue(pack, 'downloads')
+ * console.log(sortValue) // 100
+ */
+ const getSortValue = (
+ pack: RegistryNodePack,
+ sortField: string
+ ): string | number => {
+ return getActiveProvider().getSortValue(pack, sortField)
+ }
+
+ /**
+ * Get the sortable fields for the active provider.
+ * @example
+ * const sortableFields = getSortableFields()
+ * console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
+ */
+ const getSortableFields = () => {
+ return getActiveProvider().getSortableFields()
+ }
+
+ return {
+ searchPacks,
+ clearSearchCache,
+ getSortValue,
+ getSortableFields
+ }
+}
diff --git a/src/services/providers/algoliaSearchProvider.ts b/src/services/providers/algoliaSearchProvider.ts
new file mode 100644
index 000000000..71da739ae
--- /dev/null
+++ b/src/services/providers/algoliaSearchProvider.ts
@@ -0,0 +1,232 @@
+import QuickLRU from '@alloc/quick-lru'
+import type {
+ SearchQuery,
+ SearchResponse
+} from 'algoliasearch/dist/lite/browser'
+import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
+import { memoize, omit } from 'lodash'
+
+import {
+ MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA,
+ SEARCH_CACHE_MAX_SIZE
+} from '@/constants/searchConstants'
+import type {
+ AlgoliaNodePack,
+ NodesIndexSuggestion,
+ SearchAttribute,
+ SearchNodePacksParams
+} from '@/types/algoliaTypes'
+import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
+import type { components } from '@/types/comfyRegistryTypes'
+import type {
+ NodePackSearchProvider,
+ SearchPacksResult,
+ SortableField
+} from '@/types/searchServiceTypes'
+import { paramsToCacheKey } from '@/utils/formatUtil'
+
+type RegistryNodePack = components['schemas']['Node']
+
+const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
+ 'comfy_nodes',
+ 'name',
+ 'description',
+ 'latest_version',
+ 'status',
+ 'publisher_id',
+ 'total_install',
+ 'create_time',
+ 'update_time',
+ 'license',
+ 'repository_url',
+ 'latest_version_status',
+ 'comfy_node_extract_status',
+ 'id',
+ 'icon_url'
+]
+
+const searchPacksCache = new QuickLRU({
+ maxSize: SEARCH_CACHE_MAX_SIZE
+})
+
+const toRegistryLatestVersion = (
+ algoliaNode: AlgoliaNodePack
+): RegistryNodePack['latest_version'] => {
+ return {
+ version: algoliaNode.latest_version,
+ createdAt: algoliaNode.update_time,
+ status: algoliaNode.latest_version_status,
+ comfy_node_extract_status:
+ algoliaNode.comfy_node_extract_status ?? undefined
+ }
+}
+
+const toRegistryPublisher = (
+ algoliaNode: AlgoliaNodePack
+): RegistryNodePack['publisher'] => {
+ return {
+ id: algoliaNode.publisher_id,
+ name: algoliaNode.publisher_id
+ }
+}
+
+/**
+ * Convert from node pack in Algolia format to Comfy Registry format
+ */
+const toRegistryPack = memoize(
+ (algoliaNode: AlgoliaNodePack): RegistryNodePack => {
+ return {
+ id: algoliaNode.id ?? algoliaNode.objectID,
+ name: algoliaNode.name,
+ description: algoliaNode.description,
+ repository: algoliaNode.repository_url,
+ license: algoliaNode.license,
+ downloads: algoliaNode.total_install,
+ status: algoliaNode.status,
+ icon: algoliaNode.icon_url,
+ latest_version: toRegistryLatestVersion(algoliaNode),
+ publisher: toRegistryPublisher(algoliaNode),
+ // @ts-expect-error comfy_nodes also not in node info
+ comfy_nodes: algoliaNode.comfy_nodes,
+ create_time: algoliaNode.create_time
+ }
+ },
+ (algoliaNode: AlgoliaNodePack) => algoliaNode.id
+)
+
+export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
+ const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
+
+ /**
+ * Search for node packs in Algolia (internal method)
+ */
+ const searchPacksInternal = async (
+ query: string,
+ params: SearchNodePacksParams
+ ): Promise => {
+ const { pageSize, pageNumber } = params
+ const rest = omit(params, ['pageSize', 'pageNumber'])
+
+ const requests: SearchQuery[] = [
+ {
+ query,
+ indexName: 'nodes_index',
+ attributesToRetrieve: RETRIEVE_ATTRIBUTES,
+ ...rest,
+ hitsPerPage: pageSize,
+ page: pageNumber
+ }
+ ]
+
+ const shouldQuerySuggestions =
+ query.length >= MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA
+
+ // If the query is long enough, also query the suggestions index
+ if (shouldQuerySuggestions) {
+ requests.push({
+ indexName: 'nodes_index_query_suggestions',
+ query
+ })
+ }
+
+ const { results } = await searchClient.search<
+ AlgoliaNodePack | NodesIndexSuggestion
+ >({
+ requests,
+ strategy: 'none'
+ })
+
+ const [nodePacks, querySuggestions = { hits: [] }] = results as [
+ SearchResponse,
+ SearchResponse
+ ]
+
+ // Convert Algolia hits to RegistryNodePack format
+ const registryPacks = nodePacks.hits.map(toRegistryPack)
+
+ // Extract query suggestions from search results
+ const suggestions = querySuggestions.hits.map((suggestion) => ({
+ query: suggestion.query,
+ popularity: suggestion.popularity
+ }))
+
+ return {
+ nodePacks: registryPacks,
+ querySuggestions: suggestions
+ }
+ }
+
+ /**
+ * Search for node packs in Algolia with caching.
+ */
+ const searchPacks = async (
+ query: string,
+ params: SearchNodePacksParams
+ ): Promise => {
+ const cacheKey = paramsToCacheKey({ query, ...params })
+ const cachedResult = searchPacksCache.get(cacheKey)
+ if (cachedResult !== undefined) return cachedResult
+
+ const result = await searchPacksInternal(query, params)
+ searchPacksCache.set(cacheKey, result)
+ return result
+ }
+
+ const clearSearchCache = () => {
+ searchPacksCache.clear()
+ }
+
+ const getSortValue = (
+ pack: RegistryNodePack,
+ sortField: string
+ ): string | number => {
+ // For Algolia, we rely on the default sorting behavior
+ // The results are already sorted by the index configuration
+ // This is mainly used for re-sorting after results are fetched
+ switch (sortField) {
+ case SortableAlgoliaField.Downloads:
+ return pack.downloads ?? 0
+ case SortableAlgoliaField.Created: {
+ // TODO: add create time to backend return type
+ // @ts-expect-error create_time is not in the RegistryNodePack type
+ const createTime = pack.create_time
+ return createTime ? new Date(createTime).getTime() : 0
+ }
+ case SortableAlgoliaField.Updated:
+ return pack.latest_version?.createdAt
+ ? new Date(pack.latest_version.createdAt).getTime()
+ : 0
+ case SortableAlgoliaField.Publisher:
+ return pack.publisher?.name ?? ''
+ case SortableAlgoliaField.Name:
+ return pack.name ?? ''
+ default:
+ return 0
+ }
+ }
+
+ const getSortableFields = (): SortableField[] => {
+ return [
+ {
+ id: SortableAlgoliaField.Downloads,
+ label: 'Downloads',
+ direction: 'desc'
+ },
+ { id: SortableAlgoliaField.Created, label: 'Created', direction: 'desc' },
+ { id: SortableAlgoliaField.Updated, label: 'Updated', direction: 'desc' },
+ {
+ id: SortableAlgoliaField.Publisher,
+ label: 'Publisher',
+ direction: 'asc'
+ },
+ { id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
+ ]
+ }
+
+ return {
+ searchPacks,
+ clearSearchCache,
+ getSortValue,
+ getSortableFields
+ }
+}
diff --git a/src/services/providers/registrySearchProvider.ts b/src/services/providers/registrySearchProvider.ts
new file mode 100644
index 000000000..bea726a2e
--- /dev/null
+++ b/src/services/providers/registrySearchProvider.ts
@@ -0,0 +1,92 @@
+import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
+import type { SearchNodePacksParams } from '@/types/algoliaTypes'
+import type { components } from '@/types/comfyRegistryTypes'
+import type {
+ NodePackSearchProvider,
+ SearchPacksResult,
+ SortableField
+} from '@/types/searchServiceTypes'
+
+type RegistryNodePack = components['schemas']['Node']
+
+/**
+ * Search provider for the Comfy Registry.
+ * Uses public Comfy Registry API.
+ */
+export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
+ const registryStore = useComfyRegistryStore()
+
+ /**
+ * Search for node packs using the Comfy Registry API.
+ */
+ const searchPacks = async (
+ query: string,
+ params: SearchNodePacksParams
+ ): Promise => {
+ const { pageSize, pageNumber, restrictSearchableAttributes } = params
+
+ // Determine search mode based on searchable attributes
+ const isNodeSearch = restrictSearchableAttributes?.includes('comfy_nodes')
+
+ const searchParams = {
+ search: isNodeSearch ? undefined : query,
+ comfy_node_search: isNodeSearch ? query : undefined,
+ limit: pageSize,
+ offset: pageNumber * pageSize
+ }
+
+ const searchResult = await registryStore.search.call(searchParams)
+
+ if (!searchResult || !searchResult.nodes) {
+ return {
+ nodePacks: [],
+ querySuggestions: []
+ }
+ }
+
+ return {
+ nodePacks: searchResult.nodes,
+ querySuggestions: [] // Registry doesn't support query suggestions
+ }
+ }
+
+ const clearSearchCache = () => {
+ registryStore.search.clear()
+ }
+
+ const getSortValue = (
+ pack: RegistryNodePack,
+ sortField: string
+ ): string | number => {
+ switch (sortField) {
+ case 'downloads':
+ return pack.downloads ?? 0
+ case 'name':
+ return pack.name ?? ''
+ case 'publisher':
+ return pack.publisher?.name ?? ''
+ case 'updated':
+ return pack.latest_version?.createdAt
+ ? new Date(pack.latest_version.createdAt).getTime()
+ : 0
+ default:
+ return 0
+ }
+ }
+
+ const getSortableFields = (): SortableField[] => {
+ return [
+ { id: 'downloads', label: 'Downloads', direction: 'desc' },
+ { id: 'name', label: 'Name', direction: 'asc' },
+ { id: 'publisher', label: 'Publisher', direction: 'asc' },
+ { id: 'updated', label: 'Updated', direction: 'desc' }
+ ]
+ }
+
+ return {
+ searchPacks,
+ clearSearchCache,
+ getSortValue,
+ getSortableFields
+ }
+}
diff --git a/src/types/algoliaTypes.ts b/src/types/algoliaTypes.ts
index 4e00812c8..009174411 100644
--- a/src/types/algoliaTypes.ts
+++ b/src/types/algoliaTypes.ts
@@ -12,11 +12,20 @@ type SafeNestedProperty<
> = T[K1] extends undefined | null ? undefined : NonNullable[K2]
type RegistryNodePack = components['schemas']['Node']
+
+/**
+ * Result of searching the Algolia index.
+ * Represents the entire result of a search query.
+ */
export type SearchPacksResult = {
nodePacks: Hit[]
querySuggestions: Hit[]
}
+/**
+ * Node pack record after it has been mapped to Algolia index format.
+ * @see https://github.com/Comfy-Org/comfy-api/blob/main/mapper/algolia.go
+ */
export interface AlgoliaNodePack {
objectID: RegistryNodePack['id']
name: RegistryNodePack['name']
@@ -52,7 +61,14 @@ export interface AlgoliaNodePack {
icon_url: RegistryNodePack['icon']
}
+/**
+ * An attribute that can be used to search the Algolia index by.
+ */
export type SearchAttribute = keyof AlgoliaNodePack
+
+/**
+ * Suggestion for a search query (autocomplete).
+ */
export interface NodesIndexSuggestion {
nb_words: number
nodes_index: {
@@ -67,8 +83,11 @@ export interface NodesIndexSuggestion {
query: string
}
+/**
+ * Parameters for searching the Algolia index.
+ */
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
pageSize: number
pageNumber: number
- restrictSearchableAttributes: SearchAttribute[]
+ restrictSearchableAttributes?: SearchAttribute[]
}
diff --git a/src/types/comfyManagerTypes.ts b/src/types/comfyManagerTypes.ts
index ab79343b7..6a747701c 100644
--- a/src/types/comfyManagerTypes.ts
+++ b/src/types/comfyManagerTypes.ts
@@ -3,6 +3,7 @@ import type { InjectionKey, Ref } from 'vue'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
+import type { SearchMode } from '@/types/searchServiceTypes'
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
@@ -238,6 +239,6 @@ export interface UpdateAllPacksParams {
export interface ManagerState {
selectedTabId: ManagerTab
searchQuery: string
- searchMode: 'nodes' | 'packs'
- sortField: SortableAlgoliaField
+ searchMode: SearchMode
+ sortField: string
}
diff --git a/src/types/searchServiceTypes.ts b/src/types/searchServiceTypes.ts
new file mode 100644
index 000000000..768770f24
--- /dev/null
+++ b/src/types/searchServiceTypes.ts
@@ -0,0 +1,49 @@
+import type { SearchNodePacksParams } from '@/types/algoliaTypes'
+import type { components } from '@/types/comfyRegistryTypes'
+
+type RegistryNodePack = components['schemas']['Node']
+
+/**
+ * Search mode for filtering results
+ */
+export type SearchMode = 'nodes' | 'packs'
+export type QuerySuggestion = {
+ query: string
+ popularity: number
+}
+
+export interface SearchPacksResult {
+ nodePacks: RegistryNodePack[]
+ querySuggestions: QuerySuggestion[]
+}
+
+export interface SortableField {
+ id: T
+ label: string
+ direction: 'asc' | 'desc'
+}
+
+export interface NodePackSearchProvider {
+ /**
+ * Search for node packs
+ */
+ searchPacks(
+ query: string,
+ params: SearchNodePacksParams
+ ): Promise
+
+ /**
+ * Clear the search cache
+ */
+ clearSearchCache(): void
+
+ /**
+ * Get the sort value for a pack based on the sort field
+ */
+ getSortValue(pack: RegistryNodePack, sortField: string): string | number
+
+ /**
+ * Get the list of sortable fields supported by this provider
+ */
+ getSortableFields(): SortableField[]
+}
diff --git a/tests-ui/tests/services/algoliaSearchProvider.test.ts b/tests-ui/tests/services/algoliaSearchProvider.test.ts
new file mode 100644
index 000000000..515a2ff16
--- /dev/null
+++ b/tests-ui/tests/services/algoliaSearchProvider.test.ts
@@ -0,0 +1,365 @@
+import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
+import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
+
+// Mock global Algolia constants
+
+;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
+;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
+
+// Mock algoliasearch
+vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
+ liteClient: vi.fn()
+}))
+
+describe('useAlgoliaSearchProvider', () => {
+ let mockSearchClient: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Create mock search client
+ mockSearchClient = {
+ search: vi.fn()
+ }
+
+ vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
+ })
+
+ afterEach(() => {
+ // Clear the module-level cache between tests
+ const provider = useAlgoliaSearchProvider()
+ provider.clearSearchCache()
+ })
+
+ describe('searchPacks', () => {
+ it('should search for packs and convert results', async () => {
+ const mockAlgoliaResults = {
+ results: [
+ {
+ hits: [
+ {
+ objectID: 'algolia-1',
+ id: 'pack-1',
+ name: 'Test Pack',
+ description: 'A test pack',
+ publisher_id: 'publisher-1',
+ total_install: 500,
+ create_time: '2024-01-01T00:00:00Z',
+ update_time: '2024-01-15T00:00:00Z',
+ repository_url: 'https://github.com/test/pack',
+ license: 'MIT',
+ status: 'active',
+ latest_version: '1.0.0',
+ latest_version_status: 'published',
+ icon_url: 'https://example.com/icon.png',
+ comfy_nodes: ['LoadImage', 'SaveImage']
+ }
+ ]
+ },
+ { hits: [] } // Query suggestions
+ ]
+ }
+
+ mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
+
+ const provider = useAlgoliaSearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(mockSearchClient.search).toHaveBeenCalledWith({
+ requests: [
+ {
+ query: 'test',
+ indexName: 'nodes_index',
+ attributesToRetrieve: expect.any(Array),
+ hitsPerPage: 10,
+ page: 0
+ },
+ {
+ query: 'test',
+ indexName: 'nodes_index_query_suggestions'
+ }
+ ],
+ strategy: 'none'
+ })
+
+ expect(result.nodePacks).toHaveLength(1)
+ expect(result.nodePacks[0]).toEqual({
+ id: 'pack-1',
+ name: 'Test Pack',
+ description: 'A test pack',
+ repository: 'https://github.com/test/pack',
+ license: 'MIT',
+ downloads: 500,
+ status: 'active',
+ icon: 'https://example.com/icon.png',
+ latest_version: {
+ version: '1.0.0',
+ createdAt: '2024-01-15T00:00:00Z',
+ status: 'published',
+ comfy_node_extract_status: undefined
+ },
+ publisher: {
+ id: 'publisher-1',
+ name: 'publisher-1'
+ },
+ create_time: '2024-01-01T00:00:00Z',
+ comfy_nodes: ['LoadImage', 'SaveImage']
+ })
+ })
+
+ it('should include query suggestions when query is long enough', async () => {
+ const mockAlgoliaResults = {
+ results: [
+ { hits: [] }, // Main results
+ {
+ hits: [
+ { query: 'test query', popularity: 10 },
+ { query: 'test pack', popularity: 5 }
+ ]
+ }
+ ]
+ }
+
+ mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
+
+ const provider = useAlgoliaSearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ // Should make 2 requests (main + suggestions)
+ expect(mockSearchClient.search).toHaveBeenCalledWith({
+ requests: [
+ expect.objectContaining({ indexName: 'nodes_index' }),
+ expect.objectContaining({
+ indexName: 'nodes_index_query_suggestions'
+ })
+ ],
+ strategy: 'none'
+ })
+
+ expect(result.querySuggestions).toEqual([
+ { query: 'test query', popularity: 10 },
+ { query: 'test pack', popularity: 5 }
+ ])
+ })
+
+ it('should not query suggestions for short queries', async () => {
+ mockSearchClient.search.mockResolvedValue({
+ results: [{ hits: [] }]
+ })
+
+ const provider = useAlgoliaSearchProvider()
+ await provider.searchPacks('a', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ // Should only make 1 request (no suggestions)
+ expect(mockSearchClient.search).toHaveBeenCalledWith({
+ requests: [expect.objectContaining({ indexName: 'nodes_index' })],
+ strategy: 'none'
+ })
+ })
+
+ it('should cache search results', async () => {
+ mockSearchClient.search.mockResolvedValue({
+ results: [{ hits: [] }, { hits: [] }]
+ })
+
+ const provider = useAlgoliaSearchProvider()
+ const params = { pageSize: 10, pageNumber: 0 }
+
+ // First call
+ await provider.searchPacks('test', params)
+ expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
+
+ // Second call with same params should use cache
+ await provider.searchPacks('test', params)
+ expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
+
+ // Different params should make new request
+ await provider.searchPacks('test', { ...params, pageNumber: 1 })
+ expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
+ })
+
+ it('should handle missing objectID by using id field', async () => {
+ const mockAlgoliaResults = {
+ results: [
+ {
+ hits: [
+ {
+ id: 'pack-id-only',
+ name: 'Pack without objectID',
+ // ... other required fields
+ publisher_id: 'pub',
+ total_install: 0,
+ comfy_nodes: []
+ }
+ ]
+ },
+ { hits: [] }
+ ]
+ }
+
+ mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
+
+ const provider = useAlgoliaSearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(result.nodePacks[0].id).toBe('pack-id-only')
+ })
+ })
+
+ describe('clearSearchCache', () => {
+ it('should clear the cache', async () => {
+ mockSearchClient.search.mockResolvedValue({
+ results: [{ hits: [] }, { hits: [] }]
+ })
+
+ const provider = useAlgoliaSearchProvider()
+ const params = { pageSize: 10, pageNumber: 0 }
+
+ // Populate cache
+ await provider.searchPacks('test', params)
+ expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
+
+ // Clear cache
+ provider.clearSearchCache()
+
+ // Same search should hit API again
+ await provider.searchPacks('test', params)
+ expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('getSortValue', () => {
+ const testPack = {
+ id: '1',
+ name: 'Test Pack',
+ downloads: 100,
+ publisher: { id: 'pub1', name: 'Publisher One' },
+ latest_version: {
+ version: '1.0.0',
+ createdAt: '2024-01-15T10:00:00Z'
+ },
+ create_time: '2024-01-01T10:00:00Z'
+ }
+
+ it('should return correct values for each sort field', () => {
+ const provider = useAlgoliaSearchProvider()
+
+ expect(
+ provider.getSortValue(testPack, SortableAlgoliaField.Downloads)
+ ).toBe(100)
+ expect(provider.getSortValue(testPack, SortableAlgoliaField.Name)).toBe(
+ 'Test Pack'
+ )
+ expect(
+ provider.getSortValue(testPack, SortableAlgoliaField.Publisher)
+ ).toBe('Publisher One')
+
+ const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
+ expect(
+ provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
+ ).toBe(createdTimestamp)
+
+ const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
+ expect(
+ provider.getSortValue(testPack, SortableAlgoliaField.Updated)
+ ).toBe(updatedTimestamp)
+ })
+
+ it('should handle missing values', () => {
+ const incompletePack = { id: '1', name: 'Incomplete' }
+ const provider = useAlgoliaSearchProvider()
+
+ expect(
+ provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
+ ).toBe(0)
+ expect(
+ provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
+ ).toBe('')
+ expect(
+ provider.getSortValue(
+ incompletePack as any,
+ SortableAlgoliaField.Created
+ )
+ ).toBe(0)
+ expect(
+ provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
+ ).toBe(0)
+ })
+ })
+
+ describe('getSortableFields', () => {
+ it('should return all Algolia sort fields', () => {
+ const provider = useAlgoliaSearchProvider()
+ const fields = provider.getSortableFields()
+
+ expect(fields).toEqual([
+ {
+ id: SortableAlgoliaField.Downloads,
+ label: 'Downloads',
+ direction: 'desc'
+ },
+ {
+ id: SortableAlgoliaField.Created,
+ label: 'Created',
+ direction: 'desc'
+ },
+ {
+ id: SortableAlgoliaField.Updated,
+ label: 'Updated',
+ direction: 'desc'
+ },
+ {
+ id: SortableAlgoliaField.Publisher,
+ label: 'Publisher',
+ direction: 'asc'
+ },
+ { id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
+ ])
+ })
+ })
+
+ describe('memoization', () => {
+ it('should memoize toRegistryPack conversions', async () => {
+ const mockHit = {
+ objectID: 'algolia-1',
+ id: 'pack-1',
+ name: 'Test Pack',
+ publisher_id: 'pub1',
+ total_install: 100,
+ comfy_nodes: []
+ }
+
+ mockSearchClient.search.mockResolvedValue({
+ results: [
+ { hits: [mockHit, mockHit, mockHit] }, // Same object 3 times
+ { hits: [] }
+ ]
+ })
+
+ const provider = useAlgoliaSearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ // All 3 results should be the same object reference due to memoization
+ expect(result.nodePacks[0]).toBe(result.nodePacks[1])
+ expect(result.nodePacks[1]).toBe(result.nodePacks[2])
+ })
+ })
+})
diff --git a/tests-ui/tests/services/registrySearchGateway.test.ts b/tests-ui/tests/services/registrySearchGateway.test.ts
new file mode 100644
index 000000000..e51b164d1
--- /dev/null
+++ b/tests-ui/tests/services/registrySearchGateway.test.ts
@@ -0,0 +1,445 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
+import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
+import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
+
+// Mock the provider modules to control their behavior
+vi.mock('@/services/providers/algoliaSearchProvider')
+vi.mock('@/services/providers/registrySearchProvider')
+
+describe('useRegistrySearchGateway', () => {
+ let consoleWarnSpy: any
+ let consoleInfoSpy: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ consoleWarnSpy.mockRestore()
+ consoleInfoSpy.mockRestore()
+ vi.useRealTimers()
+ })
+
+ describe('Provider initialization', () => {
+ it('should initialize with both providers', () => {
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ expect(useAlgoliaSearchProvider).toHaveBeenCalled()
+ expect(useComfyRegistrySearchProvider).toHaveBeenCalled()
+ expect(gateway).toBeDefined()
+ })
+
+ it('should handle Algolia initialization failure gracefully', () => {
+ vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
+ throw new Error('Algolia init failed')
+ })
+
+ const mockRegistryProvider = {
+ searchPacks: vi
+ .fn()
+ .mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ // Gateway should still work with just the Registry provider
+ expect(gateway).toBeDefined()
+ expect(typeof gateway.searchPacks).toBe('function')
+
+ // Verify it can still search using the fallback provider
+ return expect(
+ gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
+ ).resolves.toBeDefined()
+ })
+ })
+
+ describe('Search functionality', () => {
+ it('should use Algolia provider by default and fallback on failure', async () => {
+ const algoliaResult = {
+ nodePacks: [{ id: 'algolia-1', name: 'Algolia Pack' }],
+ querySuggestions: []
+ }
+ const registryResult = {
+ nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
+ querySuggestions: []
+ }
+
+ const mockAlgoliaProvider = {
+ searchPacks: vi
+ .fn()
+ .mockResolvedValueOnce(algoliaResult)
+ .mockRejectedValueOnce(new Error('Algolia failed')),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn().mockResolvedValue(registryResult),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ // First call should use Algolia
+ const result1 = await gateway.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+ expect(result1.nodePacks[0].name).toBe('Algolia Pack')
+
+ // Second call should fallback to Registry when Algolia fails
+ const result2 = await gateway.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+ expect(result2.nodePacks[0].name).toBe('Registry Pack')
+ })
+
+ it('should throw error when all providers fail', async () => {
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ await expect(
+ gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
+ ).rejects.toThrow('All search providers failed')
+ })
+ })
+
+ describe('Circuit breaker functionality', () => {
+ it('should switch to fallback provider after failure and log warnings', async () => {
+ const registryResult = {
+ nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
+ querySuggestions: []
+ }
+
+ // Create mock that fails
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn().mockResolvedValue(registryResult),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ // First call should try Algolia, fail, and use Registry
+ const result = await gateway.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
+ expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
+ expect(result.nodePacks[0].name).toBe('Registry Pack')
+
+ // Circuit breaker behavior is internal implementation detail
+ // We only test the observable behavior (fallback works)
+ })
+
+ it('should have circuit breaker timeout mechanism', () => {
+ // This test verifies that the constants exist for circuit breaker behavior
+ // The actual circuit breaker logic is tested in integration with real provider behavior
+ expect(typeof useRegistrySearchGateway).toBe('function')
+
+ // We can test that the gateway logs circuit breaker behavior
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi
+ .fn()
+ .mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+ expect(gateway).toBeDefined()
+ })
+ })
+
+ describe('Cache management', () => {
+ it('should clear cache for all providers', () => {
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+ gateway.clearSearchCache()
+
+ expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
+ expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
+ })
+
+ it('should handle cache clear failures gracefully', () => {
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn().mockImplementation(() => {
+ throw new Error('Cache clear failed')
+ }),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ // Should not throw when clearing cache even if one provider fails
+ expect(() => gateway.clearSearchCache()).not.toThrow()
+
+ // Should still attempt to clear cache for all providers
+ expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
+ expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
+ })
+ })
+
+ describe('Sort functionality', () => {
+ it('should use sort fields from active provider', () => {
+ const algoliaFields = [
+ { id: 'downloads', label: 'Downloads', direction: 'desc' }
+ ]
+
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue(algoliaFields)
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+ const sortFields = gateway.getSortableFields()
+
+ expect(sortFields).toEqual(algoliaFields)
+ })
+
+ it('should switch sort fields when provider changes', async () => {
+ const algoliaFields = [
+ { id: 'downloads', label: 'Downloads', direction: 'desc' }
+ ]
+ const registryFields = [{ id: 'name', label: 'Name', direction: 'asc' }]
+
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue(algoliaFields)
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi
+ .fn()
+ .mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue(registryFields)
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ // Initially should use Algolia's sort fields
+ expect(gateway.getSortableFields()).toEqual(algoliaFields)
+
+ // Force a search to trigger provider switch
+ await gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
+
+ // Now should use Registry's sort fields
+ expect(gateway.getSortableFields()).toEqual(registryFields)
+ })
+
+ it('should delegate getSortValue to active provider', () => {
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn().mockReturnValue(100),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn(),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+ const pack = { id: '1', name: 'Test Pack' }
+
+ const value = gateway.getSortValue(pack, 'downloads')
+
+ expect(mockAlgoliaProvider.getSortValue).toHaveBeenCalledWith(
+ pack,
+ 'downloads'
+ )
+ expect(value).toBe(100)
+ })
+ })
+
+ describe('Provider recovery', () => {
+ it('should use fallback provider when primary fails', async () => {
+ const algoliaError = new Error('Algolia service unavailable')
+ const registryResult = {
+ nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
+ querySuggestions: []
+ }
+
+ const mockAlgoliaProvider = {
+ searchPacks: vi.fn().mockRejectedValue(algoliaError),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ const mockRegistryProvider = {
+ searchPacks: vi.fn().mockResolvedValue(registryResult),
+ clearSearchCache: vi.fn(),
+ getSortValue: vi.fn(),
+ getSortableFields: vi.fn().mockReturnValue([])
+ }
+
+ vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
+ vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
+ mockRegistryProvider
+ )
+
+ const gateway = useRegistrySearchGateway()
+
+ // Should fallback to Registry when Algolia fails
+ const result = await gateway.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(result.nodePacks[0].name).toBe('Registry Pack')
+ expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
+ expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
+
+ // The gateway successfully handled the failure and returned results
+ })
+ })
+})
diff --git a/tests-ui/tests/services/registrySearchProvider.test.ts b/tests-ui/tests/services/registrySearchProvider.test.ts
new file mode 100644
index 000000000..79e4e52f2
--- /dev/null
+++ b/tests-ui/tests/services/registrySearchProvider.test.ts
@@ -0,0 +1,186 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
+import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
+
+// Mock the store
+vi.mock('@/stores/comfyRegistryStore', () => ({
+ useComfyRegistryStore: vi.fn()
+}))
+
+describe('useComfyRegistrySearchProvider', () => {
+ const mockSearchCall = vi.fn()
+ const mockSearchClear = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Setup store mock
+ vi.mocked(useComfyRegistryStore).mockReturnValue({
+ search: {
+ call: mockSearchCall,
+ clear: mockSearchClear
+ }
+ } as any)
+ })
+
+ describe('searchPacks', () => {
+ it('should search for packs by name', async () => {
+ const mockResults = {
+ nodes: [
+ { id: '1', name: 'Test Pack 1' },
+ { id: '2', name: 'Test Pack 2' }
+ ]
+ }
+ mockSearchCall.mockResolvedValue(mockResults)
+
+ const provider = useComfyRegistrySearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0,
+ restrictSearchableAttributes: ['name', 'description']
+ })
+
+ expect(mockSearchCall).toHaveBeenCalledWith({
+ search: 'test',
+ comfy_node_search: undefined,
+ limit: 10,
+ offset: 0
+ })
+ expect(result.nodePacks).toEqual(mockResults.nodes)
+ expect(result.querySuggestions).toEqual([])
+ })
+
+ it('should search for packs by node names', async () => {
+ const mockResults = {
+ nodes: [{ id: '1', name: 'Pack with LoadImage node' }]
+ }
+ mockSearchCall.mockResolvedValue(mockResults)
+
+ const provider = useComfyRegistrySearchProvider()
+ const result = await provider.searchPacks('LoadImage', {
+ pageSize: 20,
+ pageNumber: 1,
+ restrictSearchableAttributes: ['comfy_nodes']
+ })
+
+ expect(mockSearchCall).toHaveBeenCalledWith({
+ search: undefined,
+ comfy_node_search: 'LoadImage',
+ limit: 20,
+ offset: 20
+ })
+ expect(result.nodePacks).toEqual(mockResults.nodes)
+ })
+
+ it('should handle empty results', async () => {
+ mockSearchCall.mockResolvedValue({ nodes: [] })
+
+ const provider = useComfyRegistrySearchProvider()
+ const result = await provider.searchPacks('nonexistent', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(result.nodePacks).toEqual([])
+ expect(result.querySuggestions).toEqual([])
+ })
+
+ it('should handle null results', async () => {
+ mockSearchCall.mockResolvedValue(null)
+
+ const provider = useComfyRegistrySearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(result.nodePacks).toEqual([])
+ expect(result.querySuggestions).toEqual([])
+ })
+
+ it('should handle results without nodes property', async () => {
+ mockSearchCall.mockResolvedValue({})
+
+ const provider = useComfyRegistrySearchProvider()
+ const result = await provider.searchPacks('test', {
+ pageSize: 10,
+ pageNumber: 0
+ })
+
+ expect(result.nodePacks).toEqual([])
+ expect(result.querySuggestions).toEqual([])
+ })
+ })
+
+ describe('clearSearchCache', () => {
+ it('should delegate to store search.clear', () => {
+ const provider = useComfyRegistrySearchProvider()
+ provider.clearSearchCache()
+
+ expect(mockSearchClear).toHaveBeenCalled()
+ })
+ })
+
+ describe('getSortValue', () => {
+ const testPack = {
+ id: '1',
+ name: 'Test Pack',
+ downloads: 100,
+ publisher: { id: 'pub1', name: 'Publisher One' },
+ latest_version: {
+ version: '1.0.0',
+ createdAt: '2024-01-15T10:00:00Z'
+ }
+ }
+
+ it('should return download count for downloads field', () => {
+ const provider = useComfyRegistrySearchProvider()
+ expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
+ })
+
+ it('should return pack name for name field', () => {
+ const provider = useComfyRegistrySearchProvider()
+ expect(provider.getSortValue(testPack, 'name')).toBe('Test Pack')
+ })
+
+ it('should return publisher name for publisher field', () => {
+ const provider = useComfyRegistrySearchProvider()
+ expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
+ })
+
+ it('should return timestamp for updated field', () => {
+ const provider = useComfyRegistrySearchProvider()
+ const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
+ expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
+ })
+
+ it('should handle missing values gracefully', () => {
+ const incompletePack = { id: '1', name: 'Incomplete' }
+ const provider = useComfyRegistrySearchProvider()
+
+ expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
+ expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
+ expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
+ })
+
+ it('should return 0 for unknown sort fields', () => {
+ const provider = useComfyRegistrySearchProvider()
+ expect(provider.getSortValue(testPack, 'unknown')).toBe(0)
+ })
+ })
+
+ describe('getSortableFields', () => {
+ it('should return supported sort fields', () => {
+ const provider = useComfyRegistrySearchProvider()
+ const fields = provider.getSortableFields()
+
+ expect(fields).toEqual([
+ { id: 'downloads', label: 'Downloads', direction: 'desc' },
+ { id: 'name', label: 'Name', direction: 'asc' },
+ { id: 'publisher', label: 'Publisher', direction: 'asc' },
+ { id: 'updated', label: 'Updated', direction: 'desc' }
+ ])
+ })
+ })
+})