A type-safe database abstraction layer that seamlessly integrates with React Query and popular ORMs. Yuki DB provides a declarative API for database operations with built-in caching, optimistic updates, and real-time synchronization.
- 🔥 Type-Safe: Full TypeScript support with automatic type inference
- ⚡ React Query Integration: Built-in caching, background updates, and optimistic mutations
- 🎯 Declarative API: Simple, intuitive syntax for database operations
- 🔄 Real-Time Updates: Automatic cache invalidation and synchronization
- 🛠️ ORM Agnostic: Currently supports Drizzle ORM (Prisma support coming soon)
- 📦 Framework Flexible: Works with Next.js, React Router, and other React frameworks supporting RSC
npm install yuki-db @tanstack/react-query
# or
yarn add yuki-db @tanstack/react-query
# or
pnpm add yuki-db @tanstack/react-query
# or
bun add yuki-db @tanstack/react-query
Note: This example uses PostgreSQL with Drizzle ORM, but Yuki DB works with any database that your chosen ORM supports (MySQL, SQLite, etc.).
Define your database schema:
Drizzle ORM Example:
// src/server/db/schema.ts
import { pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core'
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
content: varchar('content', { length: 1000 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
Prisma Example:
// prisma/schema.prisma
generator client {
provider = "prisma-client"
previewFeatures = ["driverAdapters"]
output = "../src/server/db/generated"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
content String @db.VarChar(1000)
createdAt DateTime @default(now())
}
model User {
id Int @id @default(autoincrement())
email String @unique @db.VarChar(255)
name String @db.VarChar(100)
createdAt DateTime @default(now())
}
Create your database connection:
Drizzle ORM Example:
// src/server/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'postgres'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
export const db = drizzle(pool)
Prisma Example:
// src/server/db.ts
import { PrismaClient } from 'path/to/generated/prisma'
export const db = new PrismaClient()
Set up the Yuki DB handlers:
Drizzle ORM Example:
// src/lib/db.ts
import { createHandler } from 'yuki-db/drizzle'
import { db } from '@/server/db'
import * as schema from '@/server/db/schema'
export const { GET, POST } = createHandler({ db, schema })
// Type augmentation for better IntelliSense
declare module 'yuki-db/drizzle' {
interface Database {
db: typeof db
schema: typeof schema
}
}
Prisma Example: (coming soon)
// src/lib/db.ts
import { createHandler } from 'yuki-db/prisma'
import type {
PostCreateInput,
PostModel,
UserCreateInput,
UserModel,
} from '@/path/to/generated/models'
import { db } from '@/server/db'
export const { GET, POST } = createHandler({ db })
declare module 'yuki-db/prisma' {
interface Database {
db: typeof db
schema: {
post: {
$inferSelect: PostModel
$inferInsert: PostCreateInput
}
user: {
$inferSelect: UserModel
$inferInsert: UserCreateInput
}
}
}
}
Next.js App Router:
// src/app/api/db/route.ts
export { GET, POST } from '@/lib/db'
React Router v7:
// src/routes/api.db.ts
import type { Route } from './+types/api.db'
import { GET, POST } from '@/lib/db'
export const loader = async ({ request }: Route.LoaderArgs) => GET(request)
export const action = async ({ request }: Route.ActionArgs) => POST(request)
Tanstack Start:
// src/routes/api.db.ts
import { createServerFileRoute } from '@tanstack/react-start/server'
import { GET, POST } from '@/lib/db'
export const ServerRoute = createServerFileRoute('/api/db').methods({
GET: ({ request }) => GET(request),
POST: ({ request }) => POST(request),
})
Configure React Query for your application:
// src/lib/query-client.ts
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 3,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
})
// src/components/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createQueryClient } from '@/lib/query-client'
let clientQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return createQueryClient()
} else {
// Browser: make a new query client if we don't already have one
return (clientQueryClient ??= createQueryClient())
}
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
Wrap your app with the providers:
// src/app/layout.tsx
import { Providers } from '@/components/providers'
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang='en'>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Use useDatabaseQuery
to fetch data with automatic caching:
import { useDatabaseQuery } from 'yuki-db'
function PostsList() {
const {
data: posts,
isLoading,
error,
refetch,
} = useDatabaseQuery({
select: {
id: true,
title: true,
content: true,
createdAt: true,
},
from: 'posts',
})
if (isLoading) return <div>Loading posts...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<button onClick={() => refetch()}>Refresh Posts</button>
{posts?.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
</article>
))}
</div>
)
}
// Complex filtering with AND/OR conditions
const { data: filteredPosts } = useDatabaseQuery({
select: { id: true, title: true },
from: 'posts',
where: {
OR: [{ title: { like: '%typescript%' } }, { content: { like: '%react%' } }],
// AND: [{ createdAt: { gte: new Date('2024-01-01') } }],
},
order: {
createdAt: 'desc',
title: 'asc',
},
limit: 20,
offset: 0,
})
// Pagination example
function PaginatedPosts() {
const [page, setPage] = useState(0)
const pageSize = 10
const { data: posts, isLoading } = useDatabaseQuery({
select: { id: true, title: true },
from: 'posts',
order: { createdAt: 'desc' },
limit: pageSize,
offset: page * pageSize,
})
return (
<div>
{/* Posts list */}
<div className='pagination'>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button>
<span>Page {page + 1}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!posts || posts.length < pageSize}
>
Next
</button>
</div>
</div>
)
}
Use useDatabaseMutation
for data modifications:
import { useDatabaseMutation } from 'yuki-db'
function CreatePostForm() {
const {
mutate: createPost,
isPending,
error,
} = useDatabaseMutation(
{
action: 'insert',
table: 'posts',
},
{
onSuccess: () => console.log('Post created'),
onError: (error) => console.error('Failed to create post:', error),
},
)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createPost({
title: formData.get('title') as string,
content: formData.get('content') as string,
})
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor='title'>Title:</label>
<input
id='title'
name='title'
type='text'
placeholder='Enter post title'
required
/>
</div>
<div>
<label htmlFor='content'>Content:</label>
<textarea
id='content'
name='content'
placeholder='Enter post content'
required
/>
</div>
{error && <div className='error'>Error: {error.message}</div>}
<button type='submit' disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
function EditPostForm({ postId }: { postId: number }) {
const { mutate: updatePost, isPending } = useDatabaseMutation(
{
action: 'update',
table: 'posts',
},
{
onSuccess: () => console.log('Post updated successfully'),
},
)
const handleUpdate = (updates: { title?: string; content?: string }) => {
updatePost({
where: { id: postId },
data: updates,
})
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
handleUpdate({
title: formData.get('title') as string,
content: formData.get('content') as string,
})
}}
>
{/* Form fields */}
<button type='submit' disabled={isPending}>
{isPending ? 'Updating...' : 'Update Post'}
</button>
</form>
)
}
function DeletePostButton({ postId }: { postId: number }) {
const { mutate: deletePost, isPending } = useDatabaseMutation(
{
action: 'delete',
table: 'posts',
},
{
onSuccess: () => console.log('Post deleted successfully'),
},
)
const handleDelete = () => {
if (confirm('Are you sure you want to delete this post?'))
deletePost({ id: postId })
}
return (
<button
onClick={handleDelete}
disabled={isPending}
className='delete-button'
>
{isPending ? 'Deleting...' : 'Delete Post'}
</button>
)
}
For better code organization and reusability:
// src/services/post.ts
import {
createDatabaseMutationOptions,
createDatabaseQueryOptions,
} from 'yuki-db'
export const postQueries = {
all: () =>
createDatabaseQueryOptions({
select: ['id', 'title', 'content', 'createdAt'],
from: 'posts',
}),
byId: (id: number) =>
createDatabaseQueryOptions({
select: ['id', 'title', 'content', 'createdAt'],
from: 'posts',
where: { id: { eq: id } },
}),
create: () =>
createDatabaseMutationOptions({
action: 'insert',
table: 'posts',
}),
update: () =>
createDatabaseMutationOptions({
action: 'update',
table: 'posts',
}),
delete: () =>
createDatabaseMutationOptions({
action: 'delete',
table: 'posts',
}),
}
// Usage in components
function PostDetail({ id }: { id: number }) {
const { data: post } = useDatabaseQuery(postQueries.byId(id))
// Alternatively, you can use the `useQuery` hook directly:
// import { useQuery } from '@tanstack/react-query'
// const { data: post } = useQuery(postQueries.byId(id))
return <div>{post?.title}</div>
}
For better loading states, you can use useDatabaseSuspenseQuery
which throws an error if the query is not yet resolved, allowing you to use React's Suspense for loading states:
import { useDatabaseSuspenseQuery } from 'yuki-db'
// Server side
export default function Page() {
const queryClient = new QueryClient()
queryClient.prefetchQuery(postQueries.all())
return (
<Suspense fallback={<div>Loading posts...</div>}>
<PostsList />
</Suspense>
)
}
// Client side
function PostsList() {
const posts = useDatabaseSuspenseQuery(postQueries.all())
return <div>...</div>
}
Use invalidateQueries
to refresh data after mutations:
import { useQueryClient } from '@tanstack/react-query'
import { createDatabaseQueryOptions } from 'yuki-db'
const queryClient = useQueryClient()
const queryOptions = createDatabaseQueryOptions({
select: { id: true, title: true },
from: 'posts',
})
queryClient.invalidateQueries({
queryKey: queryOptions.queryKey,
})
Creates API route handlers for database operations.
Drizzle ORM:
import { createHandler } from 'yuki-db/drizzle'
const { GET, POST } = createHandler({
db: drizzleInstance,
schema: schemaObject,
})
Parameters:
db
: Your Drizzle database instanceschema
: Your database schema definitions
Returns:
GET
: Handler for query operationsPOST
: Handler for mutation operations
Prisma ORM:
import { createHandler } from 'yuki-db/prisma'
const { GET, POST } = createHandler({
db: prismaInstance,
})
**Parameters:**
- `db`: Your Prisma database instance
**Returns:**
- `GET`: Handler for query operations
- `POST`: Handler for mutation operations
Hook for fetching data with automatic caching and background updates.
const result = useDatabaseQuery(
{
select: {
id: true,
title: true,
},
from: 'posts',
where: { id: { eq: a } },
order: { createdAt: 'desc' },
limit: 10,
offset: 0,
},
useQueryOptions,
)
Options:
select
: Array of column names to selectfrom
: Table name to querywhere
: Optional filter conditionsorder
: Optional sorting configurationlimit
: Maximum number of records to returnoffset
: Number of records to skip (for pagination)
Returns:
data
: Query results (undefined while loading)error
: Error object if query failed (null on success)isLoading
: True when query is loading for the first timeisError
: True when query has encountered an errorisSuccess
: True when query has completed successfullyisFetching
: True when query is fetching (including background updates)isRefetching
: True when query is refetchingisPending
: True when query is in pending stateisStale
: True when data is considered stalerefetch
: Function to manually refetch datafetchStatus
: Current fetch status ('fetching' | 'paused' | 'idle')status
: Current query status ('pending' | 'error' | 'success')
Hook for performing database mutations with optimistic updates.
const mutation = useDatabaseMutation(
{
action: 'insert' | 'update' | 'delete',
table: string,
},
UseMutationOptions,
)
Options:
action
: Type of mutation ('insert'
,'update'
,'delete'
)table
: Target table name
Returns:
data
: Mutation result data (always undefined)error
: Error object if mutation failed (null on success)isError
: True when mutation has encountered an errorisIdle
: True when mutation is in idle stateisPending
: True when mutation is currently executingisSuccess
: True when mutation has completed successfullymutate
: Function to trigger the mutationmutateAsync
: Async version of mutate that returns a Promisereset
: Function to reset mutation state to idlestatus
: Current mutation status ('idle' | 'pending' | 'error' | 'success')
Same as useDatabaseQuery
, but throws an error if the query is not yet resolved, allowing you to use React's Suspense for loading states.
Utility function to create reusable query options.
const options = createDatabaseQueryOptions({
select: { id: true, title: true },
from: 'posts',
where: { id: { eq: 1 } },
})
export type WhereOperator = 'eq' | 'like' | 'gt' | 'gte' | 'lt' | 'lte' | 'ne'
export type FieldCondition<T> = Partial<Record<WhereOperator, T>>
export type WhereClause<TSchema> = {
[K in keyof TSchema]?: FieldCondition<TSchema[K]>
} & {
OR?: WhereClause<TSchema>[]
AND?: WhereClause<TSchema>
NOT?: WhereClause<TSchema>
}
export type UpdateWhereClause<TTable extends keyof ExtractTables> = Partial<{
[K in keyof ExtractTables[TTable]['$inferSelect']]: ExtractTables[TTable]['$inferSelect'][K]
}>
export type ExtractTables = Database['schema']
export type SelectableColumns<TFrom extends keyof ExtractTables> = {
[K in keyof ExtractTables[TFrom]['$inferSelect']]?: boolean
}
export type SelectedData<TSelect, TFrom extends keyof ExtractTables> = {
[K in keyof TSelect as TSelect[K] extends true
? K
: never]: ExtractTables[TFrom]['$inferSelect'][K]
}
export type OrderClause<TFrom extends keyof ExtractTables> = Partial<
Record<keyof ExtractTables[TFrom]['$inferSelect'], 'asc' | 'desc'>
>
interface DatabaseQueryOptions<
TFrom extends keyof ExtractTables,
TSelect extends SelectableColumns<TFrom>,
TData = SelectedData<TSelect, TFrom>,
> {
select: TSelect
from: TFrom
where?: WhereClause<ExtractTables[TFrom]['$inferSelect']>
order?: OrderClause<TFrom>
limit?: number
offset?: number
}
type ActionType = 'insert' | 'update' | 'delete'
interface DatabaseMutationOptions<
TAction extends ActionType,
TTable extends keyof ExtractTables,
> {
action: TAction
table: TTable
}
Before (Direct Drizzle):
const posts = await db.select().from(schema.posts).where(eq(schema.posts.id, 1))
Before (Direct Prisma):
const posts = await db.post.findMany({
where: { id: 1 },
select: { id: true, title: true, content: true },
})
After (Yuki DB):
const { data: posts } = useDatabaseQuery({
select: ['id', 'title', 'content'],
from: 'posts',
where: { id: { eq: 1 } },
})
Issue: Type errors with schema
// Make sure you have the proper module declaration
declare module 'yuki-db/drizzle' {
interface Database {
db: typeof db
schema: typeof schema
}
}
// Or
declare module 'yuki-db/prisma' {
interface Database {
db: typeof db
schema: {
posts: Post
users: User
}
}
}
This project is licensed under the MIT License - see the LICENSE file for details.