UNPKG

@tanstack/solid-db

Version:

Solid integration for @tanstack/db

489 lines (465 loc) 13.4 kB
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup, } from 'solid-js' import { ReactiveMap } from '@solid-primitives/map' import { BaseQueryBuilder, CollectionImpl, createLiveQueryCollection, } from '@tanstack/db' import { createStore, reconcile } from 'solid-js/store' import type { Accessor } from 'solid-js' import type { ChangeMessage, Collection, CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, NonSingleResult, QueryBuilder, SingleResult, } from '@tanstack/db' /** * Create a live query using a query function * @param queryFn - Query function that defines what data to fetch * @returns Accessor that returns data with Suspense support, with state and status information as properties * @example * // Basic query with object syntax * const todosQuery = useLiveQuery((q) => * q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.completed, false)) * .select(({ todos }) => ({ id: todos.id, text: todos.text })) * ) * * @example * // With dependencies that trigger re-execution * const todosQuery = useLiveQuery( * (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => gt(todos.priority, minPriority())), * ) * * @example * // Join pattern * const personIssues = useLiveQuery((q) => * q.from({ issues: issueCollection }) * .join({ persons: personCollection }, ({ issues, persons }) => * eq(issues.userId, persons.id) * ) * .select(({ issues, persons }) => ({ * id: issues.id, * title: issues.title, * userName: persons.name * })) * ) * * @example * // Handle loading and error states * const todosQuery = useLiveQuery((q) => * q.from({ todos: todoCollection }) * ) * * return ( * <Switch> * <Match when={todosQuery.isLoading}> * <div>Loading...</div> * </Match> * <Match when={todosQuery.isError}> * <div>Error: {todosQuery.status}</div> * </Match> * <Match when={todosQuery.isReady}> * <For each={todosQuery()}> * {(todo) => <li key={todo.id}>{todo.text}</li>} * </For> * </Match> * </Switch> * ) * * @example * // Use Suspense boundaries * const todosQuery = useLiveQuery((q) => * q.from({ todos: todoCollection }) * ) * * return ( * <Suspense fallback={<div>Loading...</div>}> * <For each={todosQuery()}> * {(todo) => <li key={todo.id}>{todo.text}</li>} * </For> * </Suspense> * ) */ // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery<TContext extends Context>( queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, ): Accessor<InferResultType<TContext>> & { /** * @deprecated use function result instead * query.data -> query() */ data: InferResultType<TContext> state: ReactiveMap<string | number, GetResult<TContext>> collection: Collection<GetResult<TContext>, string | number, {}> status: CollectionStatus isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean } // Overload 1b: Accept query function that can return undefined/null export function useLiveQuery<TContext extends Context>( queryFn: ( q: InitialQueryBuilder, ) => QueryBuilder<TContext> | undefined | null, ): Accessor<InferResultType<TContext>> & { /** * @deprecated use function result instead * query.data -> query() */ data: InferResultType<TContext> state: ReactiveMap<string | number, GetResult<TContext>> collection: Collection<GetResult<TContext>, string | number, {}> | null status: CollectionStatus | `disabled` isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean } /** * Create a live query using configuration object * @param config - Configuration object with query and options * @returns Accessor that returns data with Suspense support, with state and status information as properties * @example * // Basic config object usage * const todosQuery = useLiveQuery(() => ({ * query: (q) => q.from({ todos: todosCollection }), * gcTime: 60000 * })) * * @example * // With query builder and options * const queryBuilder = new Query() * .from({ persons: collection }) * .where(({ persons }) => gt(persons.age, 30)) * .select(({ persons }) => ({ id: persons.id, name: persons.name })) * * const personsQuery = useLiveQuery(() => ({ query: queryBuilder })) * * @example * // Handle all states uniformly * const itemsQuery = useLiveQuery(() => ({ * query: (q) => q.from({ items: itemCollection }) * })) * * return ( * <Switch fallback={<div>{itemsQuery().length} items loaded</div>}> * <Match when={itemsQuery.isLoading}> * <div>Loading...</div> * </Match> * <Match when={itemsQuery.isError}> * <div>Something went wrong</div> * </Match> * <Match when={!itemsQuery.isReady}> * <div>Preparing...</div> * </Match> * </Switch> * ) */ // Overload 2: Accept config object export function useLiveQuery<TContext extends Context>( config: Accessor<LiveQueryCollectionConfig<TContext>>, ): Accessor<InferResultType<TContext>> & { /** * @deprecated use function result instead * query.data -> query() */ data: InferResultType<TContext> state: ReactiveMap<string | number, GetResult<TContext>> collection: Collection<GetResult<TContext>, string | number, {}> status: CollectionStatus isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean } /** * Subscribe to an existing live query collection * @param liveQueryCollection - Pre-created live query collection to subscribe to * @returns Accessor that returns data with Suspense support, with state and status information as properties * @example * // Using pre-created live query collection * const myLiveQuery = createLiveQueryCollection((q) => * q.from({ todos: todosCollection }).where(({ todos }) => eq(todos.active, true)) * ) * const todosQuery = useLiveQuery(() => myLiveQuery) * * @example * // Access collection methods directly * const existingQuery = useLiveQuery(() => existingCollection) * * // Use collection for mutations * const handleToggle = (id) => { * existingQuery.collection.update(id, draft => { draft.completed = !draft.completed }) * } * * @example * // Handle states consistently * const sharedQuery = useLiveQuery(() => sharedCollection) * * return ( * <Switch fallback={<div><For each={sharedQuery()}>{(item) => <Item key={item.id} {...item} />}</For></div>}> * <Match when={sharedQuery.isLoading}> * <div>Loading...</div> * </Match> * <Match when={sharedQuery.isError}> * <div>Error loading data</div> * </Match> * </Switch> * ) */ // Overload 3: Accept pre-created live query collection (non-single result) export function useLiveQuery< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( liveQueryCollection: Accessor< Collection<TResult, TKey, TUtils> & NonSingleResult >, ): Accessor<Array<TResult>> & { /** * @deprecated use function result instead * query.data -> query() */ data: Array<TResult> state: ReactiveMap<TKey, TResult> collection: Collection<TResult, TKey, TUtils> status: CollectionStatus isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean } // Overload 3b: Accept pre-created live query collection with singleResult: true export function useLiveQuery< TResult extends object, TKey extends string | number, TUtils extends Record<string, any>, >( liveQueryCollection: Accessor< Collection<TResult, TKey, TUtils> & SingleResult >, ): Accessor<TResult | undefined> & { /** * @deprecated use function result instead * query.data -> query() */ data: TResult | undefined state: ReactiveMap<TKey, TResult> collection: Collection<TResult, TKey, TUtils> & SingleResult status: CollectionStatus isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean } // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( configOrQueryOrCollection: (queryFn?: any) => any, ) { const collection = createMemo( () => { if (configOrQueryOrCollection.length === 1) { // This is a query function - check if it returns null/undefined const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder const result = configOrQueryOrCollection(queryBuilder) if (result === undefined || result === null) { // Disabled query - return null return null } return createLiveQueryCollection({ query: configOrQueryOrCollection, startSync: true, }) } const innerCollection = configOrQueryOrCollection() if (innerCollection === undefined || innerCollection === null) { // Disabled query - return null return null } if (innerCollection instanceof CollectionImpl) { innerCollection.startSyncImmediate() return innerCollection as Collection } return createLiveQueryCollection({ ...innerCollection, startSync: true, }) }, undefined, { name: `TanstackDBCollectionMemo` }, ) // Reactive state that gets updated granularly through change events const state = new ReactiveMap<string | number, any>() // Reactive data array that maintains sorted order const [data, setData] = createStore<Array<any>>([], { name: `TanstackDBData`, }) // Track collection status reactively const [status, setStatus] = createSignal( collection() ? collection()!.status : (`disabled` as const), { name: `TanstackDBStatus`, }, ) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( currentCollection: Collection<any, any, any>, ) => { setData((prev) => reconcile(Array.from(currentCollection.values()))(prev).filter(Boolean), ) } const [getDataResource] = createResource( () => ({ currentCollection: collection() }), async ({ currentCollection }) => { if (!currentCollection) { return [] } setStatus(currentCollection.status) try { await currentCollection.toArrayWhenReady() } catch (error) { setStatus(`error`) throw error } // Initialize state with current collection data batch(() => { state.clear() for (const [key, value] of currentCollection.entries()) { state.set(key, value) } syncDataFromCollection(currentCollection) setStatus(currentCollection.status) }) return data }, { name: `TanstackDBData`, deferStream: false, initialValue: data, }, ) createEffect(() => { const currentCollection = collection() if (!currentCollection) { setStatus(`disabled` as const) state.clear() setData([]) return } const subscription = currentCollection.subscribeChanges( (changes: Array<ChangeMessage<any>>) => { // Apply each change individually to the reactive state batch(() => { for (const change of changes) { switch (change.type) { case `insert`: case `update`: state.set(change.key, change.value) break case `delete`: state.delete(change.key) break } } syncDataFromCollection(currentCollection) // Update status ref on every change setStatus(currentCollection.status) }) }, { // Include initial state to ensure immediate population for pre-created collections includeInitialState: true, }, ) onCleanup(() => { subscription.unsubscribe() }) }) // We have to remove getters from the resource function so we wrap it function getData() { const currentCollection = collection() if (currentCollection) { const config: CollectionConfigSingleRowOption<any, any, any> = currentCollection.config if (config.singleResult) { // Force resource tracking so Suspense works getDataResource() return data[0] } } return getDataResource() } Object.defineProperties(getData, { data: { get() { return getData() }, }, status: { get() { return status() }, }, collection: { get() { return collection() }, }, state: { get() { return state }, }, isLoading: { get() { return status() === `loading` }, }, isReady: { get() { return status() === `ready` || status() === `disabled` }, }, isIdle: { get() { return status() === `idle` }, }, isError: { get() { return status() === `error` }, }, isCleanedUp: { get() { return status() === `cleaned-up` }, }, }) return getData }