/** * React Hooks for TanStack Query with postgres.do * * Provides React hooks that wrap TanStack Query v5 for seamless integration * with postgres.do queries and mutations. * * @example * ```typescript * import { usePostgresQuery, usePostgresMutation } from '@dotdo/tanstack' * * function UserList() { * const { data: users, isLoading } = usePostgresQuery({ * sql: 'SELECT * FROM users WHERE active = $1', * params: [true], * }) * * const { mutate: createUser } = usePostgresMutation({ * onSuccess: () => { * // Invalidate and refetch * }, * }) * * if (isLoading) return
Loading...
* return * } * ``` * * NOTE: This module requires React 18+ and @tanstack/react-query v5+ * The hooks are designed to work with TanStack Query's useQuery/useMutation * but provide a simplified API specifically for postgres.do. */ import type { BaseRecord, Collection } from './types' import { extractTablesFromSQL, filterByPartialMatch, sortByField, applyPagination, normalizeQueryParams, } from './adapter' import type { QueryAdapter, QueryParams, MutationParams, PostgresQueryKey, QueryResult, MutationContext, InvalidationPattern, PostgresQueryOptions, PostgresMutationOptions, } from './adapter' // ============================================================================ // Internal Types for TanStack Query Integration // ============================================================================ /** * Minimal QueryClient interface for type safety * This represents the subset of QueryClient methods we use */ interface QueryClientLike { invalidateQueries(options: { queryKey: readonly unknown[] }): Promise prefetchQuery(options: PostgresQueryOptions): Promise } // ============================================================================ // Hook Types // ============================================================================ /** * Query state returned by usePostgresQuery */ export interface PostgresQueryState { data: T[] | undefined error: Error | null isLoading: boolean isError: boolean isSuccess: boolean isFetching: boolean refetch: () => Promise } /** * Mutation state returned by usePostgresMutation */ export interface PostgresMutationState { data: QueryResult | undefined error: Error | null isLoading: boolean isPending: boolean isError: boolean isSuccess: boolean isIdle: boolean mutate: (variables: MutationParams) => void mutateAsync: (variables: MutationParams) => Promise> reset: () => void } /** * Options for usePostgresQuery hook */ export interface UsePostgresQueryOptions extends Omit { /** Query adapter instance */ adapter: QueryAdapter /** Transform function for results */ select?: (data: T[]) => T[] /** Placeholder data while loading */ placeholderData?: T[] | (() => T[]) /** Keep previous data while refetching */ keepPreviousData?: boolean } /** * Options for usePostgresMutation hook */ export interface UsePostgresMutationOptions { /** Query adapter instance */ adapter: QueryAdapter /** Invalidation pattern after mutation */ invalidate?: InvalidationPattern /** Called before mutation (for optimistic updates) */ onMutate?: (variables: MutationParams) => Promise | MutationContext | undefined /** Called on successful mutation */ onSuccess?: (data: QueryResult, variables: MutationParams, context: MutationContext | undefined) => void /** Called on mutation error */ onError?: (error: Error, variables: MutationParams, context: MutationContext | undefined) => void /** Called when mutation settles */ onSettled?: ( data: QueryResult | undefined, error: Error | null, variables: MutationParams, context: MutationContext | undefined ) => void } /** * Live query subscription options */ export interface UseLiveQueryOptions { /** Collection to query */ collection: Collection /** Filter function or object */ where?: Partial | ((item: T) => boolean) /** Sort configuration */ orderBy?: { field: keyof T direction: 'asc' | 'desc' } /** Limit results */ limit?: number /** Offset results */ offset?: number /** Whether the query is enabled */ enabled?: boolean } /** * Live query state */ export interface LiveQueryState { data: T[] isLoading: boolean error: Error | null } // ============================================================================ // Hook Result Types (for type-only exports) // ============================================================================ /** * Result type for usePostgresQuery * Compatible with TanStack Query's useQuery result */ export interface UsePostgresQueryResult { data: T[] | undefined error: Error | null isLoading: boolean isError: boolean isSuccess: boolean isFetching: boolean isPending: boolean isStale: boolean status: 'pending' | 'error' | 'success' fetchStatus: 'fetching' | 'paused' | 'idle' refetch: () => Promise<{ data: T[] | undefined; error: Error | null }> queryKey: PostgresQueryKey } /** * Result type for usePostgresMutation * Compatible with TanStack Query's useMutation result */ export interface UsePostgresMutationResult { data: QueryResult | undefined error: Error | null variables: MutationParams | undefined isLoading: boolean isPending: boolean isError: boolean isSuccess: boolean isIdle: boolean status: 'idle' | 'pending' | 'error' | 'success' mutate: (variables: MutationParams) => void mutateAsync: (variables: MutationParams) => Promise> reset: () => void } // ============================================================================ // Hook Factories // ============================================================================ /** * Create a usePostgresQuery hook factory. * This pattern allows frameworks to provide their own TanStack Query integration. * * @param useQuery - The framework-specific useQuery hook (e.g., from @tanstack/react-query) * @returns A configured usePostgresQuery hook * * @example * ```typescript * import { useQuery } from '@tanstack/react-query' * import { createUsePostgresQuery } from '@dotdo/tanstack' * * const usePostgresQuery = createUsePostgresQuery(useQuery) * * // Now use it in components * const { data } = usePostgresQuery(adapter, { * sql: 'SELECT * FROM users', * }) * ``` */ export function createUsePostgresQuery) => unknown>( useQuery: TUseQuery ) { return function usePostgresQuery( adapter: QueryAdapter, params: QueryParams | string ): ReturnType { const opts = normalizeQueryParams(params) const queryOptions = adapter.queryOptions(params) // Pass through additional TanStack Query options if (opts.placeholderData !== undefined) { (queryOptions as unknown as Record).placeholderData = opts.placeholderData } if (opts.keepPreviousData) { // TanStack Query v5 uses placeholderData function for keepPreviousData behavior (queryOptions as unknown as Record).placeholderData = (previousData: unknown) => previousData } if (opts.enabled !== undefined) { queryOptions.enabled = opts.enabled } const result = useQuery(queryOptions as unknown as Parameters[0]) // Apply select transform if provided if (opts.select && result && typeof result === 'object' && 'data' in (result as object)) { const r = result as { data?: unknown[] } if (r.data && Array.isArray(r.data)) { r.data = (opts.select as (data: unknown[]) => unknown[])(r.data) } return r as ReturnType } return result as ReturnType } } /** * Create a usePostgresMutation hook factory * * @example * ```typescript * import { useMutation, useQueryClient } from '@tanstack/react-query' * import { createUsePostgresMutation } from '@dotdo/tanstack' * * const usePostgresMutation = createUsePostgresMutation(useMutation, useQueryClient) * * // Now use it in components * const { mutate } = usePostgresMutation(adapter, { * invalidate: { tables: ['users'] }, * }) * mutate({ sql: 'INSERT INTO users (name) VALUES ($1)', params: ['Alice'] }) * ``` */ /** * Extended invalidation pattern with advanced features */ interface ExtendedInvalidationPattern extends InvalidationPattern { autoDetect?: boolean awaitInvalidation?: boolean shouldInvalidate?: (result: QueryResult) => boolean cascade?: Record } export function createUsePostgresMutation< TUseMutation extends (options: PostgresMutationOptions, Error, MutationParams, MutationContext>) => unknown, TUseQueryClient extends () => QueryClientLike >( useMutation: TUseMutation, useQueryClient: TUseQueryClient ) { const mutationHistory: Array<{ sql: string; params?: unknown[]; timestamp: number; success: boolean }> = [] let totalMutations = 0 let successCount = 0 let errorCount = 0 return function usePostgresMutation( adapter: QueryAdapter, options?: Omit, 'adapter'> & { invalidate?: ExtendedInvalidationPattern } ): ReturnType & { getMutationHistory: () => typeof mutationHistory; getStats: () => { totalMutations: number; successCount: number; errorCount: number; averageDurationMs: number } } { const queryClient = useQueryClient() const performInvalidation = (data: QueryResult, variables: MutationParams): void => { if (!options?.invalidate) return const invalidationPattern = options.invalidate // Allow conditional invalidation based on mutation result if (invalidationPattern.shouldInvalidate && !invalidationPattern.shouldInvalidate(data as unknown as QueryResult)) { return } const databaseQueryKey = ['postgres', adapter.database] as const if (invalidationPattern.all) { queryClient.invalidateQueries({ queryKey: databaseQueryKey }) } if (invalidationPattern.autoDetect) { const { extractTablesFromSQL: extractTables } = getTableExtractor() const affectedTables = extractTables(variables.sql) queryClient.invalidateQueries({ queryKey: databaseQueryKey, predicate: ((query: { queryKey: readonly unknown[] }) => { const querySql = query.queryKey[2] if (typeof querySql !== 'string') return false return affectedTables.some(table => querySql.toLowerCase().includes(table.toLowerCase())) }) as unknown as undefined, } as { queryKey: readonly unknown[]; predicate?: (query: { queryKey: readonly unknown[] }) => boolean }) } if (invalidationPattern.tables) { const allTables = [...invalidationPattern.tables] // Expand cascading table dependencies if (invalidationPattern.cascade) { for (const table of invalidationPattern.tables) { const cascadeDependencies = invalidationPattern.cascade[table] if (cascadeDependencies) { allTables.push(...cascadeDependencies) } } } const uniqueTables = [...new Set(allTables)] for (const _table of uniqueTables) { queryClient.invalidateQueries({ queryKey: databaseQueryKey }) } } if (invalidationPattern.queryKeys) { for (const key of invalidationPattern.queryKeys) { queryClient.invalidateQueries({ queryKey: key }) } } } // Build mutation options const mutationOptionsArg: Parameters>[0] = { onSuccess: (data, variables, context) => { performInvalidation(data, variables) options?.onSuccess?.(data, variables, context) }, } if (options?.invalidate) mutationOptionsArg.invalidate = options.invalidate if (options?.onMutate) mutationOptionsArg.onMutate = options.onMutate if (options?.onError) mutationOptionsArg.onError = options.onError if (options?.onSettled) mutationOptionsArg.onSettled = options.onSettled const mutationOptions = adapter.mutationOptions(mutationOptionsArg) const result = useMutation(mutationOptions as PostgresMutationOptions, Error, MutationParams, MutationContext>) as ReturnType return Object.assign({}, result, { getMutationHistory: () => [...mutationHistory], getStats: () => ({ totalMutations, successCount, errorCount, averageDurationMs: 0, }), }) as ReturnType & { getMutationHistory: () => typeof mutationHistory; getStats: () => { totalMutations: number; successCount: number; errorCount: number; averageDurationMs: number } } } } /** Returns the table extraction utility (exists as a seam for testability) */ function getTableExtractor(): { extractTablesFromSQL: (sql: string) => string[] } { return { extractTablesFromSQL } } /** * Create a useLiveQuery hook factory for reactive collection queries * * @example * ```typescript * import { useSyncExternalStore } from 'react' * import { createUseLiveQuery } from '@dotdo/tanstack' * * const useLiveQuery = createUseLiveQuery(useSyncExternalStore) * * // Now use it in components * const todos = useLiveQuery(collection, { * where: { completed: false }, * orderBy: { field: 'createdAt', direction: 'desc' }, * }) * ``` */ /** * Extended live query options including advanced features */ interface ExtendedLiveQueryOptions extends Omit, 'collection'> { select?: (items: T[]) => unknown groupBy?: keyof T distinct?: keyof T debounce?: number throttle?: number optimisticData?: T[] onDiff?: (diff: { added: T[]; removed: T[]; updated: Array<{ previous: T; current: T }> }) => void onError?: (error: Error) => void cursor?: { field: keyof T; after?: unknown; pageSize: number } search?: { query: string; fields: Array; fuzzy?: boolean } } export function createUseLiveQuery< TUseSyncExternalStore extends ( subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => unknown, getServerSnapshot?: () => unknown ) => unknown >( useSyncExternalStore: TUseSyncExternalStore ) { // Cache for memoized snapshots const snapshotCache = new WeakMap, { items: unknown[]; result: unknown }>() function useLiveQuery( collection: Collection, options?: ExtendedLiveQueryOptions ): unknown { const { where, orderBy, limit, offset, enabled = true } = options ?? {} const selectFn = options?.select const groupBy = options?.groupBy const distinct = options?.distinct const optimisticData = options?.optimisticData const onDiff = options?.onDiff const onError = options?.onError const cursor = options?.cursor const search = options?.search const debounce = options?.debounce const throttle = options?.throttle // For debounce/throttle, we need to track state let lastNotifyTime = 0 let debounceTimeout: ReturnType | undefined let previousItems: T[] | undefined const getSnapshot = (): unknown => { if (!enabled) return [] as T[] let items: T[] try { items = collection.getAll() } catch (error) { if (onError) { onError(error as Error) } return [] as T[] } // Append optimistic data if (optimisticData && optimisticData.length > 0) { items = [...items, ...optimisticData] } // Apply search filter if (search) { const { query: searchQuery, fields, fuzzy } = search const lowerQuery = searchQuery.toLowerCase() items = items.filter(item => { for (const field of fields) { const value = String(item[field]).toLowerCase() if (fuzzy) { // Simple fuzzy: check if the search term is a prefix of any word if (value.includes(lowerQuery)) return true } else { if (value.includes(lowerQuery)) return true } } return false }) } // Apply filter if (where) { if (typeof where === 'function') { items = items.filter(where) } else { items = filterByPartialMatch(items, where as Partial) } } // Apply sort if (orderBy) { items = sortByField(items, orderBy.field, orderBy.direction) } // Apply cursor pagination if (cursor) { const { field, after, pageSize } = cursor if (after !== undefined) { const afterIndex = items.findIndex(item => item[field] === after) if (afterIndex >= 0) { items = items.slice(afterIndex + 1) } } items = items.slice(0, pageSize) } else { items = applyPagination(items, offset, limit) } // Compute diff if (onDiff && previousItems) { const prev = previousItems const added = items.filter(item => !prev.find(p => p.id === item.id)) const removed = prev.filter(item => !items.find(c => c.id === item.id)) const updated: Array<{ previous: T; current: T }> = [] for (const current of items) { const previous = prev.find(p => p.id === current.id) if (previous && JSON.stringify(previous) !== JSON.stringify(current)) { updated.push({ previous, current }) } } if (added.length > 0 || removed.length > 0 || updated.length > 0) { onDiff({ added, removed, updated }) } } previousItems = items // Apply groupBy if (groupBy) { const groups: Record = {} for (const item of items) { const key = String(item[groupBy]) if (!groups[key]) groups[key] = [] groups[key].push(item) } return groups } // Apply distinct if (distinct) { const seen = new Set() const result: unknown[] = [] for (const item of items) { const val = item[distinct] if (!seen.has(val)) { seen.add(val) result.push(val) } } return result } // Apply select transform if (selectFn) { return selectFn(items) } // Memoization: return same reference if items haven't changed const cached = snapshotCache.get(collection as unknown as Collection) if (cached && JSON.stringify(cached.items) === JSON.stringify(items)) { return cached.result } snapshotCache.set(collection as unknown as Collection, { items: [...items], result: items }) return items } const subscribe = (onStoreChange: () => void) => { if (debounce) { // Debounced subscribe return collection.subscribe(() => { if (debounceTimeout) clearTimeout(debounceTimeout) debounceTimeout = setTimeout(() => { onStoreChange() }, debounce) }) } if (throttle) { // Throttled subscribe return collection.subscribe(() => { const now = Date.now() if (now - lastNotifyTime >= throttle) { lastNotifyTime = now onStoreChange() } }) } // Default: synchronous dedup batching // The first notification in a synchronous batch fires immediately, // subsequent ones in the same tick are coalesced. let notifying = false return collection.subscribe(() => { if (!notifying) { notifying = true onStoreChange() // Use queueMicrotask to reset the flag after the synchronous execution completes queueMicrotask(() => { notifying = false }) } }) } return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } // Attach helper methods useLiveQuery.connectionState = (collection: Collection): unknown => { return collection.getSyncState() } useLiveQuery.join = ( collection1: Collection, collection2: Collection, joinOptions: { on: (item1: T1, item2: T2) => boolean select: (item1: T1, item2: T2) => unknown } ): unknown[] => { const items1 = collection1.getAll() const items2 = collection2.getAll() const results: unknown[] = [] for (const item1 of items1) { for (const item2 of items2) { if (joinOptions.on(item1, item2)) { results.push(joinOptions.select(item1, item2)) } } } return results } useLiveQuery.multiCollection = ( store: { getCollection: (id: string) => Collection | undefined }, collectionIds: string[], multiOptions: { combine: (...collections: unknown[][]) => unknown } ): unknown => { const getSnapshot = () => { const allItems = collectionIds.map(id => { const col = store.getCollection(id) return col ? col.getAll() : [] }) return multiOptions.combine(...allItems) } const subscribe = (onStoreChange: () => void) => { // Subscribe to all collections const unsubscribes: Array<() => void> = [] for (const id of collectionIds) { const col = store.getCollection(id) if (col) { unsubscribes.push(col.subscribe(() => onStoreChange())) } } return () => { unsubscribes.forEach(u => u()) } } return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } useLiveQuery.aggregate = ( store: { getCollection: (id: string) => Collection | undefined }, aggregateOptions: { collections: string[] aggregation: Record unknown> } ): unknown => { // Gather all items from all collections const allItems: unknown[] = [] for (const id of aggregateOptions.collections) { const col = store.getCollection(id) if (col) { allItems.push(...col.getAll()) } } const result: Record = {} for (const [key, fn] of Object.entries(aggregateOptions.aggregation)) { result[key] = fn(allItems) } return result } return useLiveQuery as typeof useLiveQuery & { connectionState: typeof useLiveQuery.connectionState join: typeof useLiveQuery.join multiCollection: typeof useLiveQuery.multiCollection aggregate: typeof useLiveQuery.aggregate } } // ============================================================================ // Collection Hooks // ============================================================================ /** * Minimal mutation options for collection hooks */ interface CollectionMutationOptionsLike { mutationKey: readonly unknown[] mutationFn: (variables: TVariables) => Promise onSuccess?: ((data: TData) => void) | undefined onError?: ((error: Error) => void) | undefined } /** * Create hook for collection insert operations */ export function createUseCollectionInsert< TUseMutation extends (options: CollectionMutationOptionsLike) => unknown >(useMutation: TUseMutation) { return function useCollectionInsert( collection: Collection, options?: { onSuccess?: (data: T) => void onError?: (error: Error) => void } ): ReturnType { return useMutation & { id?: T['id'] }>({ mutationKey: ['postgres', 'collection', collection.id, 'insert'], mutationFn: async (data: Omit & { id?: T['id'] }) => { return collection.insert(data) }, onSuccess: options?.onSuccess, onError: options?.onError, }) as ReturnType } } /** * Create hook for collection update operations */ export function createUseCollectionUpdate< TUseMutation extends (options: CollectionMutationOptionsLike) => unknown >(useMutation: TUseMutation) { return function useCollectionUpdate( collection: Collection, options?: { onSuccess?: (data: T) => void onError?: (error: Error) => void } ): ReturnType { return useMutation }>({ mutationKey: ['postgres', 'collection', collection.id, 'update'], mutationFn: async ({ id, data }: { id: string | number; data: Partial }) => { return collection.update(id, data) }, onSuccess: options?.onSuccess, onError: options?.onError, }) as ReturnType } } /** * Create hook for collection delete operations */ export function createUseCollectionDelete< TUseMutation extends (options: CollectionMutationOptionsLike) => unknown >(useMutation: TUseMutation) { return function useCollectionDelete( collection: Collection, options?: { onSuccess?: () => void onError?: (error: Error) => void } ): ReturnType { return useMutation({ mutationKey: ['postgres', 'collection', collection.id, 'delete'], mutationFn: async (id: string | number) => { return collection.delete(id) }, onSuccess: options?.onSuccess as (() => void) | undefined, onError: options?.onError, }) as ReturnType } } // ============================================================================ // Utility Hooks // ============================================================================ /** * Create a hook for prefetching queries */ export function createUsePrefetchQuery< TUseQueryClient extends () => QueryClientLike >(useQueryClient: TUseQueryClient) { return function usePrefetchQuery(adapter: QueryAdapter) { const queryClient = useQueryClient() return async (params: QueryParams | string) => { const queryOptions = adapter.queryOptions(params) return queryClient.prefetchQuery(queryOptions) } } } /** * Create a hook for invalidating queries */ export function createUseInvalidateQueries< TUseQueryClient extends () => QueryClientLike >(useQueryClient: TUseQueryClient) { return function useInvalidateQueries(adapter: QueryAdapter) { const queryClient = useQueryClient() return { /** Invalidate a specific query by its SQL and parameters */ invalidateQuery: (params: QueryParams | string) => { const opts = normalizeQueryParams(params) const queryKey = adapter.getQueryKey(opts.sql, opts.params) return queryClient.invalidateQueries({ queryKey }) }, /** Invalidate queries involving specific tables */ invalidateTables: (tables: string[]) => { return queryClient.invalidateQueries({ queryKey: ['postgres', adapter.database], predicate: (query: { queryKey: readonly unknown[] }) => { const sql = query.queryKey[2] if (typeof sql !== 'string') return true return tables.some(table => sql.toLowerCase().includes(table.toLowerCase()) ) }, } as { queryKey: readonly unknown[]; predicate?: unknown }) }, /** Invalidate queries matching a pattern */ invalidatePattern: (pattern: RegExp) => { return queryClient.invalidateQueries({ queryKey: ['postgres'] as const, predicate: (query: { queryKey: readonly unknown[] }) => { const sql = query.queryKey[2] if (typeof sql !== 'string') return false return pattern.test(sql) }, } as Parameters[0]) }, /** Invalidate all queries for this database */ invalidateAll: () => { return queryClient.invalidateQueries({ queryKey: ['postgres', adapter.database], }) }, } } } // ============================================================================ // Re-exports for convenience // ============================================================================ export { createQueryKey, normalizeSQL, normalizeQueryParams, extractTablesFromSQL, getMutationType, isReadOnlyQuery, filterByPartialMatch, sortByField, applyPagination, } from './adapter'