Files
simstudioai--sim/apps/sim/hooks/selectors/use-selector-query.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

196 lines
6.0 KiB
TypeScript

import { useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
import type {
SelectorKey,
SelectorOption,
SelectorPage,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
search?: string
detailId?: string
enabled?: boolean
}
export interface SelectorOptionsResult {
data: SelectorOption[] | undefined
isLoading: boolean
isFetching: boolean
/**
* True while paginated selectors are draining remaining pages in the
* background. Always false for non-paginated selectors.
*/
isFetchingMore: boolean
/**
* True when the paginated selector still has more pages queued. Always false
* for non-paginated selectors.
*/
hasMore: boolean
/**
* True when the paginated drain stopped at {@link MAX_AUTO_DRAIN_PAGES} with
* pages still remaining, so the option list is a partial view. Always false
* for non-paginated selectors.
*/
truncated: boolean
error: Error | null
}
const logger = createLogger('SelectorQuery')
const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined }
/**
* Safety bound on the background auto-drain. Real dropdowns settle in a handful
* of pages; this only trips for pathological result sets and prevents an
* unbounded request loop when a provider keeps handing back cursors.
*/
const MAX_AUTO_DRAIN_PAGES = 50
export function useSelectorOptions(
key: SelectorKey,
args: SelectorHookArgs
): SelectorOptionsResult {
const definition = getSelectorDefinition(key)
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
search: args.search,
}
const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
const supportsPagination = Boolean(definition.fetchPage)
const flatQuery = useQuery<SelectorOption[]>({
queryKey: definition.getQueryKey(queryArgs),
queryFn: ({ signal }) =>
definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]),
enabled: !supportsPagination && isEnabled,
staleTime: definition.staleTime ?? 30_000,
})
const pagedQuery = useInfiniteQuery<SelectorPage>({
queryKey: [...definition.getQueryKey(queryArgs), 'paged'],
queryFn: ({ pageParam, signal }) => {
if (!definition.fetchPage) return Promise.resolve(EMPTY_PAGE)
return definition.fetchPage({
...queryArgs,
cursor: pageParam as string | undefined,
signal,
})
},
getNextPageParam: (last) => last.nextCursor,
initialPageParam: undefined as string | undefined,
enabled: supportsPagination && isEnabled,
staleTime: definition.staleTime ?? 30_000,
})
const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery
const pageCount = pagedQuery.data?.pages.length ?? 0
const reachedDrainCap = pageCount >= MAX_AUTO_DRAIN_PAGES
useEffect(() => {
if (!supportsPagination) return
if (isError) return
if (reachedDrainCap) {
if (hasNextPage) {
logger.warn('Selector hit auto-drain cap; option list is truncated', {
key,
pages: pageCount,
})
}
return
}
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage()
}
}, [
supportsPagination,
hasNextPage,
isFetchingNextPage,
isError,
fetchNextPage,
reachedDrainCap,
pageCount,
key,
])
const pagedOptions = useMemo<SelectorOption[] | undefined>(() => {
if (!supportsPagination) return undefined
if (!pagedQuery.data) return undefined
return pagedQuery.data.pages.flatMap((page) => page.items)
}, [supportsPagination, pagedQuery.data])
if (supportsPagination) {
return {
data: pagedOptions,
isLoading: pagedQuery.isLoading,
isFetching: pagedQuery.isFetching,
isFetchingMore: pagedQuery.isFetchingNextPage,
hasMore: (pagedQuery.hasNextPage ?? false) && !reachedDrainCap,
truncated: reachedDrainCap && (pagedQuery.hasNextPage ?? false),
error: (pagedQuery.error as Error | null) ?? null,
}
}
return {
data: flatQuery.data,
isLoading: flatQuery.isLoading,
isFetching: flatQuery.isFetching,
isFetchingMore: false,
hasMore: false,
truncated: false,
error: (flatQuery.error as Error | null) ?? null,
}
}
export function useSelectorOptionDetail(
key: SelectorKey,
args: SelectorHookArgs & { detailId?: string }
) {
const { data: envVariables = {} } = usePersonalEnvironment()
const definition = getSelectorDefinition(key)
const resolvedDetailId = useMemo(() => {
if (!args.detailId) return undefined
if (isReference(args.detailId)) return undefined
if (isEnvVarReference(args.detailId)) {
const varName = extractEnvVarName(args.detailId)
return envVariables[varName]?.value || undefined
}
return args.detailId
}, [args.detailId, envVariables])
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
detailId: resolvedDetailId,
}
const hasRealDetailId = Boolean(resolvedDetailId)
const baseEnabled =
hasRealDetailId && definition.fetchById !== undefined
? definition.enabled
? definition.enabled(queryArgs)
: true
: false
const enabled = args.enabled ?? baseEnabled
const query = useQuery<SelectorOption | null>({
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }),
enabled,
staleTime: definition.staleTime ?? 300_000,
})
return query
}
export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) {
return useMemo(() => {
const merged = mergeOption(options, extra)
return new Map(merged.map((option) => [option.id, option]))
}, [options, extra])
}