@blujosi/orpc-db-collection
v0.1.16
Published
oRPC collection for TanStack DB - Framework agnostic real-time data synchronization
Maintainers
Readme
oRPC TanStack DB Collection
oRPC TanStack DB Collection is a powerful, framework-agnostic integration that combines oRPC with TanStack DB to provide a seamless, type-safe, real-time data synchronization solution for modern web applications.
Key Features
- Framework Agnostic: Works seamlessly with React, Svelte, Vue, Solid, Angular, and vanilla JavaScript
- Full Type Safety: Leverages oRPC's powerful type inference for end-to-end type safety
- Real-time Synchronization: Server-Sent Events (SSE) for instant updates across clients
- Optimistic UI Updates: Immediate UI feedback with background synchronization
- Offline-First: Optional localStorage persistence with stale-while-revalidate strategy
- Full Backend Control: Unlike solutions like ElectricSQL, you maintain complete ownership of your backend
- Easy Integration: Simple API that works with your existing oRPC routers
- Backward Compatible: Includes aliases for tRPC migration (
TrpcItem,TrpcSync,trpcCollectionOptions)
How It Works
This package provides a bridge between your oRPC routers and TanStack DB collections. It handles:
- Initial Data Loading: Fetches initial data from your oRPC
listhandler - Real-time Updates: Subscribes to server events via SSE for instant synchronization
- CRUD Operations: Provides optimized create, update, and delete operations
- Conflict Resolution: Handles event deduplication and race conditions
Installation
Core Package
npm install orpc-db-collection @tanstack/db @tanstack/store @orpc/server
# or
pnpm add orpc-db-collection @tanstack/db @tanstack/store @orpc/server
# or
yarn add orpc-db-collection @tanstack/db @tanstack/store @orpc/serverFramework-Specific Packages
Choose the package for your framework:
Svelte:
pnpm add @tanstack/svelte-dbReact:
pnpm add @tanstack/react-dbVue:
pnpm add @tanstack/vue-dbSolid:
pnpm add @tanstack/solid-dbAngular:
pnpm add @tanstack/angular-dbBasic Usage
1. Define Your oRPC Router
First, create an oRPC router that follows the required structure:
// src/lib/orpc/todos.ts
import { authedHandler } from '@/lib/orpc'
import { z } from 'zod'
import { eq, gt } from 'drizzle-orm'
import {
todosTable,
eventsTable,
selectTodoSchema,
createTodoSchema,
updateTodoSchema,
} from '@/db/schema'
import { OrpcSync } from 'orpc-db-collection/server'
type Todo = z.infer<typeof selectTodoSchema>
const todoRouterSync = new OrpcSync<Todo>()
// Helper function for SSE event tracking
function tracked<T>(id: string, data: T): { id: string; data: T } {
return { id, data }
}
export const todosRouter = {
list: authedHandler.input(z.void()).handler(async ({ context }) => {
return context.db
.select()
.from(todosTable)
.where(eq(todosTable.userId, context.session.user.id))
}),
create: authedHandler
.input(createTodoSchema)
.handler(async ({ context, input }) => {
const [newTodo] = await context.db
.insert(todosTable)
.values({ ...input, userId: context.session.user.id })
.returning()
const eventId = await todoRouterSync.registerEvent({
currentUserId: context.session.user.id,
event: { action: 'insert', data: newTodo },
saveEvent: (event) => saveEventToDb(context.db, event),
})
return { item: newTodo, eventId }
}),
update: authedHandler
.input(z.object({ id: z.number(), data: updateTodoSchema }))
.handler(async ({ context, input }) => {
const [updatedTodo] = await context.db
.update(todosTable)
.set(input.data)
.where(eq(todosTable.id, input.id))
.returning()
const eventId = await todoRouterSync.registerEvent({
currentUserId: context.session.user.id,
event: { action: 'update', data: updatedTodo },
saveEvent: (event) => saveEventToDb(context.db, event),
})
return { item: updatedTodo, eventId }
}),
delete: authedHandler
.input(z.object({ id: z.number() }))
.handler(async ({ context, input }) => {
const [deletedTodo] = await context.db
.delete(todosTable)
.where(eq(todosTable.id, input.id))
.returning()
const eventId = await todoRouterSync.registerEvent({
currentUserId: context.session.user.id,
event: { action: 'delete', data: deletedTodo },
saveEvent: (event) => saveEventToDb(context.db, event),
})
return { item: deletedTodo, eventId }
}),
listen: authedHandler
.input(z.object({ lastEventId: z.number().optional() }))
.handler(async function* ({ context, input }) {
const userId = context.session.user.id
const eventStream = todoRouterSync.listen({
userId,
fetchLastEvents: async (lastEventId) => {
const events = await context.db
.select()
.from(eventsTable)
.where(
eq(eventsTable.userId, userId),
gt(eventsTable.id, lastEventId)
)
.orderBy(eventsTable.id)
return events.map((event) => ({
id: event.id,
action: event.action as 'insert' | 'update' | 'delete',
data: event.data as Todo,
userId: event.userId,
}))
},
})
for await (const event of eventStream) {
yield tracked(event.id.toString(), event)
}
}),
}Key Differences from tRPC:
- oRPC uses plain objects instead of
router()wrapper - Handlers use
.handler()instead of.query()and.mutation() - Context parameter is
contextinstead ofctx - Subscriptions use async generators with
tracked()for SSE event IDs - Direct handler calls:
orpc.todos.list()instead oforpc.todos.list.query()
2. Create the Collection
// src/lib/collections.ts
import { createCollection } from '@tanstack/db'
import { orpc } from '@/lib/orpc-client'
import { orpcCollectionOptions } from 'orpc-db-collection'
export const todosCollection = createCollection(
orpcCollectionOptions({
tableName: 'todos', // Required: Name of the database table
orpcRouter: orpc.todos,
rowUpdateMode: 'partial', // or 'full'
localStorage: true, // Enable offline support
whereFilter: () => true, // Optional: Filter function for list queries
})
)3. Use in Your Components
Svelte 5 Example
<!-- src/routes/todos/+page.svelte -->
<script lang="ts">
import { useLiveQuery } from '@tanstack/svelte-db'
import { todosCollection } from '$lib/collections'
const { data: todos } = useLiveQuery((q) =>
q.from({ todos: todosCollection }).orderBy('createdAt', 'desc')
)
function toggleTodo(todo) {
todosCollection.update(todo.id, { completed: !todo.completed })
}
</script>
<div>
{#each $todos as todo (todo.id)}
<div>
<input
type="checkbox"
checked={todo.completed}
onchange={() => toggleTodo(todo)}
/>
{todo.title}
</div>
{/each}
</div>React Example
// src/routes/todos.tsx
import { useLiveQuery } from '@tanstack/react-db'
import { todosCollection } from '@/lib/collections'
function TodosPage() {
const { data: todos } = useLiveQuery((q) =>
q.from({ todos: todosCollection }).orderBy('createdAt', 'desc')
)
return (
<div>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
)
}
function TodoItem({ todo }) {
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() =>
todosCollection.update(todo.id, { completed: !todo.completed })
}
/>
{todo.title}
</div>
)
}Local Storage Persistence
The library supports local storage persistence to provide offline capabilities and faster initial load times. When enabled, your collection data will be automatically saved to and restored from localStorage, with a stale-while-revalidate mechanism: the up-to-date data will be fetched in the background and replace your cache when available.
Benefits of Local Storage Persistence
- Offline Support: Your application can continue to function when the network is unavailable
- Faster Load Times: Data is immediately available from localStorage while the initial sync happens in the background
- Reduced Server Load: Fewer initial data fetches on page reload
How to Enable Local Storage
To enable local storage persistence, configure the orpcCollectionOptions with the localStorage option:
import { orpcCollectionOptions } from 'orpc-db-collection'
import { createCollection } from '@tanstack/db'
import { orpc } from '@/lib/orpc-client'
const todosCollection = createCollection(
orpcCollectionOptions({
tableName: 'todos', // Required: Name of the database table
orpcRouter: orpc.todos,
rowUpdateMode: 'partial', // or 'full'
localStorage: true, // Enable local storage persistence (default: true)
whereFilter: () => true, // Optional: Filter function for list queries
})
)Configure Serializer (Optional)
By default, the library uses JSON for serialization when storing data in localStorage. You can customize this by providing your own serializer:
import { orpcCollectionOptions, jsonSerializer } from 'orpc-db-collection'
// Use default JSON serializer (default)
const collectionConfig = orpcCollectionOptions({
tableName: 'todos',
orpcRouter: orpc.todos,
serializer: jsonSerializer, // optional, this is the default
})
// Or use a custom serializer for complex types
const customSerializer = {
parse: (text: string) => JSON.parse(text),
stringify: (value: any) => JSON.stringify(value),
}
const collectionConfigWithCustom = orpcCollectionOptions({
tableName: 'todos',
orpcRouter: orpc.todos,
serializer: customSerializer,
})Collection Options
The orpcCollectionOptions function accepts:
interface OrpcCollectionConfig<TItem extends OrpcItem> {
tableName: string // Required: Name of the database table
orpcRouter: RequiredOrpcRouter<TItem>
rowUpdateMode?: 'partial' | 'full' // default: 'partial'
localStorage?: boolean // default: true
whereFilter?: () => boolean // Optional: Filter function for list queries
loggerConfig?: {
enabled?: boolean
level?: 'debug' | 'info' | 'error' | 'none'
}
serializer?: {
parse: <T>(text: string) => T
stringify: <T>(value: T) => string
}
// Plus all standard TanStack DB CollectionConfig options
}
interface RequiredOrpcRouter<TItem extends OrpcItem> {
list: (input: {
tableName: string
whereFilter?: () => boolean
}) => Promise<TItem[]>
create: (
input: Omit<TItem, 'id'> & {
tableName: string
}
) => Promise<OrpcMutationResponse<TItem>>
update: (input: {
tableName: string
id: TItem['id']
data: Partial<TItem>
}) => Promise<OrpcMutationResponse<TItem>>
delete: (input: {
id: TItem['id']
tableName: string
}) => Promise<OrpcMutationResponse<TItem>>
listen: {
subscribe: (
input: { lastEventId: number | null },
callbacks: {
onData: (event: OrpcSyncEvent<TItem>) => void
onError?: (error: Error) => void
}
) => { unsubscribe: () => void }
}
}
interface OrpcMutationResponse<TItem> {
item: TItem
eventId: number
}Backward Compatibility
For migration from tRPC, the following aliases are available:
import {
trpcCollectionOptions, // alias for orpcCollectionOptions
TrpcItem, // alias for OrpcItem
TrpcSync, // alias for OrpcSync
TrpcSyncEvent, // alias for OrpcSyncEvent
} from 'orpc-db-collection'Real-time Synchronization
The package handles real-time synchronization through:
- Server-Sent Events (SSE): Efficient unidirectional updates from server to clients
- Event Deduplication: Prevents duplicate processing of the same event
- Race Condition Handling: Buffers events during initial sync to maintain consistency
- Optimistic Updates: Immediate UI feedback while waiting for server confirmation
Example Project
Check out the example project for a complete working implementation using Svelte 5 + oRPC. It demonstrates:
- Database schema with Drizzle ORM
- Complete oRPC router implementation with SSE subscriptions
- Collection setup and usage with TanStack Svelte DB
- Svelte 5 components with runes
- Authentication with Better Auth
- Vite plugin for oRPC API endpoints
- Real-time synchronization across multiple clients
Migration Guide
From tRPC to oRPC
If you're migrating from the tRPC version of this library:
Update package name:
trpc-db-collection→orpc-db-collectionUpdate imports:
// Before import { trpcCollectionOptions, TrpcSync } from 'trpc-db-collection' // After import { orpcCollectionOptions, OrpcSync } from 'orpc-db-collection' // Or use backward-compatible aliases import { trpcCollectionOptions, TrpcSync } from 'orpc-db-collection'Update router structure:
// Before (tRPC) export const todosRouter = router({ list: authedProcedure.query(async ({ ctx }) => { /* ... */ }), create: authedProcedure.input(schema).mutation(async ({ ctx, input }) => { /* ... */ }), }) // After (oRPC) export const todosRouter = { list: authedHandler.input(z.void()).handler(async ({ context }) => { /* ... */ }), create: authedHandler.input(schema).handler(async ({ context, input }) => { /* ... */ }), }Update client calls:
// Before (tRPC) await trpc.todos.list.query() await trpc.todos.create.mutate({ title: 'New todo' }) // After (oRPC) await orpc.todos.list() await orpc.todos.create({ title: 'New todo' })Update subscriptions:
// Before (tRPC) listen: authedProcedure.subscription(({ ctx }) => { return todoRouterSync.eventsSubscription({ /* ... */ }) }) // After (oRPC) listen: authedHandler .input(z.object({ lastEventId: z.number().optional() })) .handler(async function* ({ context, input }) { const eventStream = todoRouterSync.listen({ /* ... */ }) for await (const event of eventStream) { yield tracked(event.id.toString(), event) } })
From ElectricSQL
If you're migrating from ElectricSQL, this package provides a similar real-time experience but with:
- Full backend control (no vendor lock-in)
- Complete type safety (thanks to oRPC)
- Simpler architecture (no separate sync service)
- Framework flexibility (works with React, Svelte, Vue, Solid, Angular)
From Traditional REST APIs
If you're using traditional REST APIs with manual state management:
- Replace REST endpoints with oRPC handlers
- Replace manual state management with TanStack DB collections
- Replace polling with SSE subscriptions for real-time updates
- Use
useLiveQueryfrom your framework's TanStack DB adapter
Performance Considerations
HTTP/2 Recommendation
For optimal performance with multiple collections, use HTTP/2 to avoid browser connection limits. The example project includes Caddy configuration for this.
Connection Management
The package automatically handles connection cleanup and reconnection logic.
Contributing
Contributions are welcome! Please open issues for bugs or feature requests, and submit pull requests for improvements.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Related Resources
- TanStack DB Documentation
- oRPC Documentation
- Svelte 5 Documentation
- Example Project Source (Svelte 5 + oRPC)
Framework-Specific Resources
- Svelte: TanStack Svelte DB
- React: TanStack React DB
- Vue: TanStack Vue DB
- Solid: TanStack Solid DB
- Angular: TanStack Angular DB
