diff --git a/src/lib/SeamQueryProvider.tsx b/src/lib/SeamQueryProvider.tsx new file mode 100644 index 0000000..755141b --- /dev/null +++ b/src/lib/SeamQueryProvider.tsx @@ -0,0 +1,348 @@ +import type { + SeamHttp, + SeamHttpEndpoints, + SeamHttpOptionsWithClientSessionToken, +} from '@seamapi/http/connect' +import { + QueryClient, + QueryClientContext, + QueryClientProvider, +} from '@tanstack/react-query' +import { + createContext, + type JSX, + type PropsWithChildren, + useContext, + useEffect, + useMemo, +} from 'react' + +import { useSeamClient } from './use-seam-client.js' + +export interface SeamQueryContext { + client: SeamHttp | null + endpointClient: SeamHttpEndpoints | null + clientOptions?: SeamQueryProviderClientOptions | undefined + publishableKey?: string | undefined + userIdentifierKey?: string | undefined + clientSessionToken?: string | undefined + consoleSessionToken?: string | undefined + workspaceId?: string | undefined + queryKeyPrefix?: string | undefined +} + +export type SeamQueryProviderProps = + | SeamQueryProviderPropsWithClient + | SeamQueryProviderPropsWithPublishableKey + | SeamQueryProviderPropsWithClientSessionToken + | SeamQueryProviderPropsWithConsoleSessionToken + +export interface SeamQueryProviderPropsWithClient + extends SeamQueryProviderBaseProps { + client: SeamHttp + queryKeyPrefix: string +} + +export interface SeamQueryProviderPropsWithPublishableKey + extends SeamQueryProviderBaseProps, + SeamQueryProviderClientOptions { + publishableKey: string + userIdentifierKey?: string +} + +export interface SeamQueryProviderPropsWithClientSessionToken + extends SeamQueryProviderBaseProps, + SeamQueryProviderClientOptions { + clientSessionToken: string +} + +export interface SeamQueryProviderPropsWithConsoleSessionToken + extends SeamQueryProviderBaseProps, + SeamQueryProviderClientOptions { + consoleSessionToken: string + workspaceId?: string | undefined +} + +interface SeamQueryProviderBaseProps extends PropsWithChildren { + queryClient?: QueryClient | undefined + onSessionUpdate?: (client: SeamHttp) => void +} + +type SeamClientOptions = SeamHttpOptionsWithClientSessionToken + +export type SeamQueryProviderClientOptions = Pick< + SeamClientOptions, + 'endpoint' | 'isUndocumentedApiEnabled' +> + +const defaultQueryClient = new QueryClient() + +export function SeamQueryProvider({ + children, + onSessionUpdate = () => {}, + queryClient, + ...props +}: SeamQueryProviderProps): JSX.Element { + const value = useMemo(() => { + const context = createSeamQueryContextValue(props) + if ( + context.client == null && + context.publishableKey == null && + context.clientSessionToken == null && + context.consoleSessionToken == null + ) { + return defaultSeamQueryContextValue + } + return context + }, [props]) + + if ( + value.client == null && + value.publishableKey == null && + value.clientSessionToken == null && + value.consoleSessionToken == null + ) { + throw new Error( + `Must provide either a Seam client, clientSessionToken, publishableKey or consoleSessionToken.`, + ) + } + + const { Provider } = seamContext + const queryClientFromContext = useContext(QueryClientContext) + + if ( + queryClientFromContext != null && + queryClient != null && + queryClientFromContext !== queryClient + ) { + throw new Error( + 'The QueryClient passed into SeamQueryProvider is different from the one in the existing QueryClientContext. Omit the queryClient prop from SeamProvider or SeamQueryProvider to use the existing QueryClient provided by the QueryClientProvider.', + ) + } + + return ( + + + {children} + + + ) +} + +function Session({ + onSessionUpdate, + children, +}: Required> & + PropsWithChildren): JSX.Element | null { + const { client } = useSeamClient() + useEffect(() => { + if (client != null) onSessionUpdate(client) + }, [onSessionUpdate, client]) + + return <>{children} +} + +const createDefaultSeamQueryContextValue = (): SeamQueryContext => { + return { client: null, endpointClient: null } +} + +const createSeamQueryContextValue = ( + options: SeamQueryProviderProps, +): SeamQueryContext => { + if (isSeamQueryProviderPropsWithClient(options)) { + if (options.queryKeyPrefix == null) { + throw new InvalidSeamQueryProviderProps( + 'The client prop must be used with a queryKeyPrefix prop.', + ) + } + return { + ...options, + endpointClient: null, + } + } + + if (isSeamQueryProviderPropsWithClientSessionToken(options)) { + const { clientSessionToken, ...clientOptions } = options + return { + clientSessionToken, + clientOptions, + client: null, + endpointClient: null, + } + } + + if (isSeamQueryProviderPropsWithPublishableKey(options)) { + const { publishableKey, userIdentifierKey, ...clientOptions } = options + return { + publishableKey, + userIdentifierKey, + clientOptions, + client: null, + endpointClient: null, + } + } + + if (isSeamQueryProviderPropsWithConsoleSessionToken(options)) { + const { consoleSessionToken, workspaceId, ...clientOptions } = options + return { + consoleSessionToken, + workspaceId, + clientOptions, + client: null, + endpointClient: null, + } + } + + return { client: null, endpointClient: null } +} + +const defaultSeamQueryContextValue = createDefaultSeamQueryContextValue() + +export const seamContext = createContext( + defaultSeamQueryContextValue, +) + +export function useSeamQueryContext(): SeamQueryContext { + return useContext(seamContext) +} + +const isSeamQueryProviderPropsWithClient = ( + props: SeamQueryProviderProps, +): props is SeamQueryProviderPropsWithClient => { + if (!('client' in props)) return false + + const { client, ...otherProps } = props + if (client == null) return false + + const otherNonNullProps = Object.values(otherProps).filter((v) => v != null) + if (otherNonNullProps.length > 0) { + throw new InvalidSeamQueryProviderProps( + `The client prop cannot be used with ${otherNonNullProps.join(' or ')}.`, + ) + } + + return true +} + +const isSeamQueryProviderPropsWithPublishableKey = ( + props: SeamQueryProviderProps, +): props is SeamQueryProviderPropsWithPublishableKey & + SeamQueryProviderClientOptions => { + if (!('publishableKey' in props)) return false + + const { publishableKey } = props + if (publishableKey == null) return false + + if ('client' in props && props.client != null) { + throw new InvalidSeamQueryProviderProps( + 'The client prop cannot be used with the publishableKey prop.', + ) + } + + if ('clientSessionToken' in props && props.clientSessionToken != null) { + throw new InvalidSeamQueryProviderProps( + 'The clientSessionToken prop cannot be used with the publishableKey prop.', + ) + } + + if ('consoleSessionToken' in props && props.consoleSessionToken != null) { + throw new InvalidSeamQueryProviderProps( + 'The consoleSessionToken prop cannot be used with the publishableKey prop.', + ) + } + + if ('workspaceId' in props && props.workspaceId != null) { + throw new InvalidSeamQueryProviderProps( + 'The workspaceId prop cannot be used with the publishableKey prop.', + ) + } + + return true +} + +const isSeamQueryProviderPropsWithClientSessionToken = ( + props: SeamQueryProviderProps, +): props is SeamQueryProviderPropsWithClientSessionToken & + SeamQueryProviderClientOptions => { + if (!('clientSessionToken' in props)) return false + + const { clientSessionToken } = props + if (clientSessionToken == null) return false + + if ('client' in props && props.client != null) { + throw new InvalidSeamQueryProviderProps( + 'The client prop cannot be used with the clientSessionToken prop.', + ) + } + + if ('publishableKey' in props && props.publishableKey != null) { + throw new InvalidSeamQueryProviderProps( + 'The publishableKey prop cannot be used with the clientSessionToken prop.', + ) + } + + if ('userIdentifierKey' in props && props.userIdentifierKey != null) { + throw new InvalidSeamQueryProviderProps( + 'The userIdentifierKey prop cannot be used with the clientSessionToken prop.', + ) + } + + if ('consoleSessionToken' in props && props.consoleSessionToken != null) { + throw new InvalidSeamQueryProviderProps( + 'The consoleSessionToken prop cannot be used with the clientSessionToken prop.', + ) + } + + if ('workspaceId' in props && props.workspaceId != null) { + throw new InvalidSeamQueryProviderProps( + 'The workspaceId prop cannot be used with the clientSessionToken prop.', + ) + } + + return true +} + +const isSeamQueryProviderPropsWithConsoleSessionToken = ( + props: SeamQueryProviderProps, +): props is SeamQueryProviderPropsWithConsoleSessionToken & + SeamQueryProviderClientOptions => { + if (!('consoleSessionToken' in props)) return false + + const { consoleSessionToken } = props + if (consoleSessionToken == null) return false + + if ('client' in props && props.client != null) { + throw new InvalidSeamQueryProviderProps( + 'The client prop cannot be used with the publishableKey prop.', + ) + } + + if ('clientSessionToken' in props && props.clientSessionToken != null) { + throw new InvalidSeamQueryProviderProps( + 'The clientSessionToken prop cannot be used with the publishableKey prop.', + ) + } + + if ('publishableKey' in props && props.publishableKey != null) { + throw new InvalidSeamQueryProviderProps( + 'The publishableKey prop cannot be used with the consoleSessionToken prop.', + ) + } + + if ('userIdentifierKey' in props && props.userIdentifierKey != null) { + throw new InvalidSeamQueryProviderProps( + 'The userIdentifierKey prop cannot be used with the consoleSessionToken prop.', + ) + } + + return true +} + +class InvalidSeamQueryProviderProps extends Error { + constructor(message: string) { + super(`SeamQueryProvider received invalid props: ${message}`) + this.name = this.constructor.name + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 7b85954..71da3e2 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1 +1,7 @@ -export default null +export * from './SeamQueryProvider.js' +export * from './use-seam-client.js' +export * from './use-seam-infinite-query.js' +export * from './use-seam-mutation.js' +export * from './use-seam-mutation-without-workspace.js' +export * from './use-seam-query.js' +export * from './use-seam-query-without-workspace.js' diff --git a/src/lib/use-seam-client.ts b/src/lib/use-seam-client.ts new file mode 100644 index 0000000..c7cdc2c --- /dev/null +++ b/src/lib/use-seam-client.ts @@ -0,0 +1,225 @@ +import { + SeamHttp, + SeamHttpEndpoints, + SeamHttpEndpointsWithoutWorkspace, + SeamHttpWithoutWorkspace, +} from '@seamapi/http/connect' +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { useSeamQueryContext } from './SeamQueryProvider.js' + +export function useSeamClient(): { + client: SeamHttp | null + endpointClient: SeamHttpEndpoints | null + clientWithoutWorkspace: SeamHttpWithoutWorkspace | null + endpointClientWithoutWorkspace: SeamHttpEndpointsWithoutWorkspace | null + queryKeyPrefixes: string[] + isPending: boolean + isError: boolean + error: unknown +} { + const { + client, + clientOptions, + publishableKey, + clientSessionToken, + consoleSessionToken, + workspaceId, + queryKeyPrefix, + ...context + } = useSeamQueryContext() + const userIdentifierKey = useUserIdentifierKeyOrFingerprint( + clientSessionToken != null ? '' : context.userIdentifierKey, + ) + + const { isPending, isError, error, data } = useQuery<{ + client: SeamHttp | null + endpointClient: SeamHttpEndpoints | null + clientWithoutWorkspace: SeamHttpWithoutWorkspace | null + endpointClientWithoutWorkspace: SeamHttpEndpointsWithoutWorkspace | null + }>({ + queryKey: [ + ...getQueryKeyPrefixes({ queryKeyPrefix }), + 'client', + { + client, + clientOptions, + publishableKey, + userIdentifierKey, + clientSessionToken, + }, + ], + queryFn: async () => { + if (client != null) + return { + client, + endpointClient: SeamHttpEndpoints.fromClient(client.client), + clientWithoutWorkspace: null, + endpointClientWithoutWorkspace: null, + } + + if (clientSessionToken != null) { + const seam = SeamHttp.fromClientSessionToken( + clientSessionToken, + clientOptions, + ) + + return { + client: seam, + endpointClient: SeamHttpEndpoints.fromClient(seam.client), + clientWithoutWorkspace: null, + endpointClientWithoutWorkspace: null, + } + } + + if (publishableKey != null) { + const seam = await SeamHttp.fromPublishableKey( + publishableKey, + userIdentifierKey, + clientOptions, + ) + + return { + client: seam, + endpointClient: SeamHttpEndpoints.fromClient(seam.client), + clientWithoutWorkspace: null, + endpointClientWithoutWorkspace: null, + } + } + + if (consoleSessionToken != null) { + const clientWithoutWorkspace = + SeamHttpWithoutWorkspace.fromConsoleSessionToken(consoleSessionToken) + + const endpointClientWithoutWorkspace = + SeamHttpEndpointsWithoutWorkspace.fromClient( + clientWithoutWorkspace.client, + ) + + if (workspaceId == null) { + return { + client: null, + endpointClient: null, + clientWithoutWorkspace, + endpointClientWithoutWorkspace, + } + } + + const seam = SeamHttp.fromConsoleSessionToken( + consoleSessionToken, + workspaceId, + clientOptions, + ) + + return { + client: seam, + endpointClient: SeamHttpEndpoints.fromClient(seam.client), + clientWithoutWorkspace, + endpointClientWithoutWorkspace, + } + } + + throw new Error( + 'Missing either a client, publishableKey, clientSessionToken, or consoleSessionToken.', + ) + }, + }) + + return { + client: data?.client ?? null, + endpointClient: data?.endpointClient ?? null, + clientWithoutWorkspace: data?.clientWithoutWorkspace ?? null, + endpointClientWithoutWorkspace: + data?.endpointClientWithoutWorkspace ?? null, + queryKeyPrefixes: getQueryKeyPrefixes({ + queryKeyPrefix, + userIdentifierKey, + publishableKey, + clientSessionToken, + consoleSessionToken, + workspaceId, + }), + isPending, + isError, + error, + } +} + +export class NullSeamClientError extends Error { + constructor() { + super( + [ + 'Attempted to use a null Seam client.', + 'Either a hook using useSeamClient was called outside of a SeamProvider or SeamQueryProvider,', + 'or there was an error when creating the Seam client in useSeamClient,', + 'or useSeamClient is still loading the client.', + ].join(' '), + ) + this.name = this.constructor.name + Error.captureStackTrace(this, this.constructor) + } +} + +function useUserIdentifierKeyOrFingerprint( + userIdentifierKey: string | undefined, +): string { + useEffect(() => { + if (userIdentifierKey != null) return + // eslint-disable-next-line no-console + console.warn(`Using an automatically generated fingerprint for the Seam userIdentifierKey! +The user interface will show warnings when using a fingerprint. +This is not recommended because the client session is now bound to this machine and is effectively ephemeral.`) + }, [userIdentifierKey]) + + if (userIdentifierKey != null) { + return userIdentifierKey + } + + const fingerprint = + globalThis.localStorage?.getItem('seam_user_fingerprint') ?? + `fingerprint_${uuidv4()}` + + globalThis.localStorage?.setItem('seam_user_fingerprint', fingerprint) + + return fingerprint +} + +const getQueryKeyPrefixes = ({ + queryKeyPrefix, + userIdentifierKey, + publishableKey, + clientSessionToken, + consoleSessionToken, + workspaceId, +}: { + queryKeyPrefix: string | undefined + userIdentifierKey?: string + publishableKey?: string | undefined + clientSessionToken?: string | undefined + consoleSessionToken?: string | undefined + workspaceId?: string | undefined +}): string[] => { + const seamPrefix = 'seam' + + if (queryKeyPrefix != null) return [seamPrefix, queryKeyPrefix] + + if (clientSessionToken != null) { + return [seamPrefix, clientSessionToken] + } + + if (publishableKey != null && userIdentifierKey != null) { + return [seamPrefix, publishableKey, userIdentifierKey] + } + + if (consoleSessionToken != null) { + if (workspaceId != null) { + return [seamPrefix, consoleSessionToken, workspaceId] + } + + return [seamPrefix, consoleSessionToken, 'without_workspace'] + } + + return [seamPrefix] +} diff --git a/src/lib/use-seam-infinite-query.ts b/src/lib/use-seam-infinite-query.ts new file mode 100644 index 0000000..5ff93f0 --- /dev/null +++ b/src/lib/use-seam-infinite-query.ts @@ -0,0 +1,103 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, + SeamHttpEndpointPaginatedQueryPaths, + SeamHttpEndpoints, + SeamHttpInvalidInputError, + SeamHttpRequest, + SeamPageCursor, +} from '@seamapi/http/connect' +import type { ActionAttempt } from '@seamapi/types/connect' +import { + type QueryKey, + useInfiniteQuery, + type UseInfiniteQueryOptions, + type UseInfiniteQueryResult, +} from '@tanstack/react-query' + +import { useSeamClient } from 'lib/use-seam-client.js' + +export type UseSeamInfiniteQueryParameters< + T extends SeamHttpEndpointPaginatedQueryPaths, +> = Parameters[0] + +export type UseSeamInfiniteQueryResult< + T extends SeamHttpEndpointPaginatedQueryPaths, +> = UseInfiniteQueryResult, QueryError> + +export function useSeamInfiniteQuery< + T extends SeamHttpEndpointPaginatedQueryPaths, +>( + endpointPath: T, + parameters: UseSeamInfiniteQueryParameters = {}, + options: Parameters[1] & + QueryOptions, QueryError> = {}, +): UseSeamInfiniteQueryResult & { queryKey: QueryKey } { + const { endpointClient: client, queryKeyPrefixes } = useSeamClient() + + if ('page_cursor' in (parameters ?? {})) { + throw new Error('Cannot use page_cursor with useSeamInfiniteQuery') + } + + const queryKey = [ + ...queryKeyPrefixes, + ...endpointPath.split('/').filter((v) => v !== ''), + parameters ?? {}, + ] + const result = useInfiniteQuery({ + enabled: client != null, + ...options, + queryKey, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextPageCursor, + queryFn: async ({ pageParam }) => { + if (client == null) + return { + data: [] as Awaited>, + nextPageCursor: null, + } + // Using @ts-expect-error over any is preferred, but not possible here because TypeScript will run out of memory. + // Type assertion is needed here for performance reasons. The types are correct at runtime. + const endpoint = client[endpointPath] as (...args: any) => any + const request = endpoint(parameters, options) + const pages = client.createPaginator(request as SeamHttpRequest) + if (pageParam == null) { + const [data, { nextPageCursor }] = await pages.firstPage() + return { + data: data as Awaited>, + nextPageCursor, + } + } + // Type assertion is needed for pageParam since the Seam API expects a branded PageCursor type. + const [data, { nextPageCursor }] = await pages.nextPage( + pageParam as SeamPageCursor, + ) + return { + data: data as Awaited>, + nextPageCursor, + } + }, + }) + return { ...result, queryKey } +} + +interface QueryData { + data: Awaited> + nextPageCursor: SeamPageCursor | null +} + +type QueryError = + | Error + | SeamHttpApiError + | SeamHttpInvalidInputError + | (QueryData['data'] extends ActionAttempt + ? + | SeamActionAttemptFailedError['data']> + | SeamActionAttemptTimeoutError['data']> + : never) + +type QueryOptions = Omit< + UseInfiniteQueryOptions, + 'queryKey' | 'queryFn' | 'initialPageParam' | 'getNextPageParam' +> diff --git a/src/lib/use-seam-mutation-without-workspace.ts b/src/lib/use-seam-mutation-without-workspace.ts new file mode 100644 index 0000000..12af4d8 --- /dev/null +++ b/src/lib/use-seam-mutation-without-workspace.ts @@ -0,0 +1,56 @@ +import type { + SeamHttpApiError, + SeamHttpEndpointsWithoutWorkspace, + SeamHttpEndpointWithoutWorkspaceMutationPaths, + SeamHttpInvalidInputError, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationOptions, + type UseMutationResult, +} from '@tanstack/react-query' + +import { NullSeamClientError, useSeamClient } from 'lib/use-seam-client.js' + +export type UseSeamMutationWithoutWorkspaceVariables< + T extends SeamHttpEndpointWithoutWorkspaceMutationPaths, +> = Parameters[0] + +export type UseSeamMutationWithoutWorkspaceResult< + T extends SeamHttpEndpointWithoutWorkspaceMutationPaths, +> = UseMutationResult< + MutationData, + MutationError, + UseSeamMutationWithoutWorkspaceVariables +> + +export function useSeamMutationWithoutWorkspace< + T extends SeamHttpEndpointWithoutWorkspaceMutationPaths, +>( + endpointPath: T, + options: Parameters[1] & + MutationOptions< + MutationData, + MutationError, + UseSeamMutationWithoutWorkspaceVariables + > = {}, +): UseSeamMutationWithoutWorkspaceResult { + const { endpointClient: client } = useSeamClient() + return useMutation({ + ...options, + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + // Using @ts-expect-error over any is preferred, but not possible here because TypeScript will run out of memory. + // Type assertion is needed here for performance reasons. The types are correct at runtime. + const endpoint = client[endpointPath] as (...args: any) => Promise + return await endpoint(variables, options) + }, + }) +} + +type MutationData = + Awaited> + +type MutationError = Error | SeamHttpApiError | SeamHttpInvalidInputError + +type MutationOptions = Omit, 'mutationFn'> diff --git a/src/lib/use-seam-mutation.ts b/src/lib/use-seam-mutation.ts new file mode 100644 index 0000000..8fb0933 --- /dev/null +++ b/src/lib/use-seam-mutation.ts @@ -0,0 +1,64 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, + SeamHttpEndpointMutationPaths, + SeamHttpEndpoints, + SeamHttpInvalidInputError, +} from '@seamapi/http/connect' +import type { ActionAttempt } from '@seamapi/types/connect' +import { + useMutation, + type UseMutationOptions, + type UseMutationResult, +} from '@tanstack/react-query' + +import { NullSeamClientError, useSeamClient } from 'lib/use-seam-client.js' + +export type UseSeamMutationVariables = + Parameters[0] + +export type UseSeamMutationResult = + UseMutationResult< + MutationData, + MutationError, + UseSeamMutationVariables + > + +export function useSeamMutation( + endpointPath: T, + options: Parameters[1] & + MutationOptions< + MutationData, + MutationError, + UseSeamMutationVariables + > = {}, +): UseSeamMutationResult { + const { endpointClient: client } = useSeamClient() + return useMutation({ + ...options, + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + // Using @ts-expect-error over any is preferred, but not possible here because TypeScript will run out of memory. + // Type assertion is needed here for performance reasons. The types are correct at runtime. + const endpoint = client[endpointPath] as (...args: any) => Promise + return await endpoint(variables, options) + }, + }) +} + +type MutationData = Awaited< + ReturnType +> + +type MutationError = + | Error + | SeamHttpApiError + | SeamHttpInvalidInputError + | (MutationData extends ActionAttempt + ? + | SeamActionAttemptFailedError> + | SeamActionAttemptTimeoutError> + : never) + +type MutationOptions = Omit, 'mutationFn'> diff --git a/src/lib/use-seam-query-without-workspace.ts b/src/lib/use-seam-query-without-workspace.ts new file mode 100644 index 0000000..c3c3b23 --- /dev/null +++ b/src/lib/use-seam-query-without-workspace.ts @@ -0,0 +1,59 @@ +import type { + SeamHttpApiError, + SeamHttpEndpointsWithoutWorkspace, + SeamHttpEndpointWithoutWorkspaceQueryPaths, + SeamHttpInvalidInputError, +} from '@seamapi/http/connect' +import { + type QueryKey, + useQuery, + type UseQueryOptions, + type UseQueryResult, +} from '@tanstack/react-query' + +import { useSeamClient } from 'lib/use-seam-client.js' + +export type UseSeamQueryWithoutWorkspaceParameters< + T extends SeamHttpEndpointWithoutWorkspaceQueryPaths, +> = Parameters[0] + +export type UseSeamQueryWithoutWorkspaceResult< + T extends SeamHttpEndpointWithoutWorkspaceQueryPaths, +> = UseQueryResult, QueryError> + +export function useSeamQueryWithoutWorkspace< + T extends SeamHttpEndpointWithoutWorkspaceQueryPaths, +>( + endpointPath: T, + parameters: UseSeamQueryWithoutWorkspaceParameters = {}, + options: Parameters[1] & + QueryOptions, QueryError> = {}, +): UseSeamQueryWithoutWorkspaceResult & { queryKey: QueryKey } { + const { endpointClient: client, queryKeyPrefixes } = useSeamClient() + const queryKey = [ + ...queryKeyPrefixes, + ...endpointPath.split('/').filter((v) => v !== ''), + parameters ?? {}, + ] + const result = useQuery({ + enabled: client != null, + ...options, + queryKey, + queryFn: async () => { + if (client == null) return null + // Using @ts-expect-error over any is preferred, but not possible here because TypeScript will run out of memory. + // Type assertion is needed here for performance reasons. The types are correct at runtime. + const endpoint = client[endpointPath] as (...args: any) => Promise + return await endpoint(parameters, options) + }, + }) + return { ...result, queryKey } +} + +type QueryData = Awaited< + ReturnType +> + +type QueryError = Error | SeamHttpApiError | SeamHttpInvalidInputError + +type QueryOptions = Omit, 'queryKey' | 'queryFn'> diff --git a/src/lib/use-seam-query.ts b/src/lib/use-seam-query.ts new file mode 100644 index 0000000..8d25d2e --- /dev/null +++ b/src/lib/use-seam-query.ts @@ -0,0 +1,66 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, + SeamHttpEndpointQueryPaths, + SeamHttpEndpoints, + SeamHttpInvalidInputError, +} from '@seamapi/http/connect' +import type { ActionAttempt } from '@seamapi/types/connect' +import { + type QueryKey, + useQuery, + type UseQueryOptions, + type UseQueryResult, +} from '@tanstack/react-query' + +import { useSeamClient } from 'lib/use-seam-client.js' + +export type UseSeamQueryParameters = + Parameters[0] + +export type UseSeamQueryResult = + UseQueryResult, QueryError> + +export function useSeamQuery( + endpointPath: T, + parameters: UseSeamQueryParameters = {}, + options: Parameters[1] & + QueryOptions, SeamHttpApiError> = {}, +): UseSeamQueryResult & { queryKey: QueryKey } { + const { endpointClient: client, queryKeyPrefixes } = useSeamClient() + const queryKey = [ + ...queryKeyPrefixes, + ...endpointPath.split('/').filter((v) => v !== ''), + parameters ?? {}, + ] + const result = useQuery({ + enabled: client != null, + ...options, + queryKey, + queryFn: async () => { + if (client == null) return null + // Using @ts-expect-error over any is preferred, but not possible here because TypeScript will run out of memory. + // Type assertion is needed here for performance reasons. The types are correct at runtime. + const endpoint = client[endpointPath] as (...args: any) => Promise + return await endpoint(parameters, options) + }, + }) + return { ...result, queryKey } +} + +type QueryData = Awaited< + ReturnType +> + +type QueryError = + | Error + | SeamHttpApiError + | SeamHttpInvalidInputError + | (QueryData extends ActionAttempt + ? + | SeamActionAttemptFailedError> + | SeamActionAttemptTimeoutError> + : never) + +type QueryOptions = Omit, 'queryKey' | 'queryFn'>