/**
* 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 {users?.map(u => - {u.name}
)}
* }
* ```
*
* 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'