CustomAdapter
CustomAdapter enables you to connect vibecode-db to your own backend APIs—REST, GraphQL, or any custom protocol—while maintaining the same type-safe query interface.
Overview
CustomAdapter is a flexible adapter that lets you define custom handlers for CRUD operations. Each handler receives the complete query state built by vibecode-db's QueryBuilder, allowing you to transform it into API calls in any format your backend requires.
Use CustomAdapter when you need to:
- Connect to internal REST APIs
- Integrate third-party services (Airtable, Notion, Firebase, etc.)
- Use custom protocols (GraphQL, gRPC, WebSockets)
- Add middleware layers (caching, rate limiting, request transformation)
- Migrate between backends while keeping queries unchanged
How It Works
When you build a query using vibecode-db's fluent API, the QueryBuilder creates a query state object containing filters, ordering, pagination, and projection details. CustomAdapter passes this state to your handlers, where you transform it into your API's format.
1// User query
2await db.from('posts')
3 .eq('userId', 123)
4 .gt('views', 100)
5 .order('created_at', { ascending: false })
6 .limit(10)
7 .select('id, title, body')
8
9// Handler receives this context
10{
11 table: 'posts',
12 state: {
13 filters: [
14 { type: 'eq', column: 'userId', value: 123 },
15 { type: 'gt', column: 'views', value: 100 }
16 ],
17 order: { column: 'created_at', ascending: false },
18 limit: 10,
19 rawProjection: 'id, title, body'
20 }
21}
22
23// You transform to your API format
24GET /posts?userId=123&views_gt=100&sort=created_at:desc&limit=10Quick Start
Using the REST Helper (Recommended)
For standard REST APIs, use createRESTHandlers for quick setup:
1import { createClient } from '@vibecode-db/client'
2import { CustomAdapter, createRESTHandlers } from '@vibecode-db/client'
3
4const db = createClient({
5 dbSpec: { schema: DBSchema },
6 adapter: (dbSpec) => new CustomAdapter(dbSpec, {
7 handlers: createRESTHandlers({
8 baseUrl: 'https://api.example.com',
9 headers: () => ({
10 'Authorization': `Bearer ${YOUR_TOKEN}`,
11 'Content-Type': 'application/json'
12 })
13 })
14 })
15})Manual Implementation
For full control, implement handlers yourself:
1const db = createClient({
2 dbSpec: { schema: DBSchema },
3 adapter: (dbSpec) => new CustomAdapter(dbSpec, {
4 handlers: {
5 select: async (projection, ctx) => {
6 // Transform query state to your API format
7 const url = buildURL(ctx.table, ctx.state)
8 const response = await fetch(url)
9 const data = await response.json()
10 return { data, error: null }
11 },
12 insert: async (values, ctx) => {
13 const response = await fetch(`${API_BASE}/${ctx.table}`, {
14 method: 'POST',
15 body: JSON.stringify(values)
16 })
17 return { data: await response.json(), error: null }
18 },
19 update: async (patch, ctx) => {
20 // Use filters to target specific rows
21 const url = buildURLWithFilters(ctx.table, ctx.state.filters)
22 const response = await fetch(url, {
23 method: 'PATCH',
24 body: JSON.stringify(patch)
25 })
26 return { data: await response.json(), error: null }
27 },
28 delete: async (ctx) => {
29 const url = buildURLWithFilters(ctx.table, ctx.state.filters)
30 await fetch(url, { method: 'DELETE' })
31 return { data: null, error: null }
32 }
33 }
34 })
35})Handler System
CustomAdapter requires four handler functions—one for each CRUD operation.
SelectHandler
Handles SELECT queries. Receives projection string and full query state.
1type SelectHandler = (
2 projection: string | undefined,
3 ctx: CustomAdapterContext
4) => Promise<{ data: any; error: Error | null }>Key responsibilities:
- Transform filters to your API's query parameters
- Apply ordering, limit, and range/pagination
- Handle projection/field selection (if supported by your API)
- Return fetched data
InsertHandler
Handles INSERT operations. Receives values to insert.
1type InsertHandler = (
2 values: any | any[],
3 ctx: { table: string }
4) => Promise<{ data: any; error: Error | null }>Key responsibilities:
- Send values to your API's create endpoint
- Handle single or bulk inserts
- Return inserted data (with generated IDs if applicable)
UpdateHandler
Handles UPDATE operations. Receives patch object and query state.
1type UpdateHandler = (
2 patch: Record<string, unknown>,
3 ctx: CustomAdapterContext
4) => Promise<{ data: any; error: Error | null }>Key responsibilities:
- Use
ctx.state.filtersto determine which rows to update - Apply partial updates
- Return updated data
DeleteHandler
Handles DELETE operations. Receives query state for filtering.
1type DeleteHandler = (
2 ctx: CustomAdapterContext
3) => Promise<{ data: null; error: Error | null }>Key responsibilities:
- Use
ctx.state.filtersto determine which rows to delete - Execute deletion
- Return null data on success
Context Object
Each handler receives a context object with the table name and query state:
1interface CustomAdapterContext {
2 table: string // Table being queried (e.g., 'users')
3 state: QueryState // Complete query state
4}
5
6interface QueryState {
7 filters: FilterOp[] // Filter operations
8 order?: OrderSpec // Ordering spec
9 limit?: number // Row limit
10 range?: { from: number; to: number } // Range for pagination
11 projectionAst?: ProjectionNode // Parsed projection tree
12 rawProjection?: string // Original projection string
13}Filter Operations
All filter types and their state representations:
| Query Builder | State Object | Type |
|---|---|---|
.eq('status', 'active') | { type: 'eq', column: 'status', value: 'active' } | Equal |
.ne('role', 'admin') | { type: 'ne', column: 'role', value: 'admin' } | Not equal |
.gt('age', 18) | { type: 'gt', column: 'age', value: 18 } | Greater than |
.gte('score', 100) | { type: 'gte', column: 'score', value: 100 } | Greater/equal |
.lt('price', 50) | { type: 'lt', column: 'price', value: 50 } | Less than |
.lte('stock', 10) | { type: 'lte', column: 'stock', value: 10 } | Less/equal |
.in('id', [1,2,3]) | { type: 'in', column: 'id', value: [1,2,3] } | In array |
.like('name', '%john%') | { type: 'like', column: 'name', value: '%john%' } | Pattern match |
REST Helper Utility
The createRESTHandlers function provides pre-built handlers for common REST API patterns, following PostgREST conventions by default.
Configuration
1createRESTHandlers({
2 baseUrl: string // Required: API base URL
3 formatFilter?: (filter) => string // Optional: Custom filter formatting
4 headers?: () => Record | Promise<Record> // Optional: Request headers
5 handleError?: (response) => Promise<Error> // Optional: Error handling
6})Basic Usage
1handlers: createRESTHandlers({
2 baseUrl: 'https://api.example.com'
3})Custom Filter Formatting
Adapt to your API's query parameter format:
1handlers: createRESTHandlers({
2 baseUrl: 'https://api.example.com',
3 formatFilter: (filter) => {
4 switch (filter.type) {
5 case 'eq':
6 return `${filter.column}=${filter.value}`
7 case 'gt':
8 return `${filter.column}_min=${filter.value}`
9 case 'lt':
10 return `${filter.column}_max=${filter.value}`
11 case 'in':
12 return `${filter.column}=${filter.value.join(',')}`
13 case 'like':
14 return `${filter.column}_contains=${filter.value.replace(/%/g, '')}`
15 default:
16 return ''
17 }
18 }
19})Default format (PostgREST-style):
1eq('status', 'active') → 'status=eq.active'
2gt('age', 18) → 'age=gt.18'
3in('id', [1,2,3]) → 'id=in.(1,2,3)'
4like('name', '%john%') → 'name=like.%john%'Authentication
Provide headers for authentication:
1handlers: createRESTHandlers({
2 baseUrl: 'https://api.example.com',
3 headers: async () => ({
4 'Authorization': `Bearer ${await getToken()}`,
5 'Content-Type': 'application/json',
6 'X-API-Key': process.env.API_KEY
7 })
8})Error Handling
Customize error responses:
1handlers: createRESTHandlers({
2 baseUrl: 'https://api.example.com',
3 handleError: async (response) => {
4 const body = await response.json()
5 return new Error(`API Error ${response.status}: ${body.message}`)
6 }
7})Configuration Options
Initialization Hook
Use onInit for setup tasks that should run once when the adapter is created:
1new CustomAdapter(dbSpec, {
2 handlers: createRESTHandlers({ baseUrl: API_URL }),
3 onInit: async () => {
4 // Authenticate
5 const { token } = await authenticate()
6 localStorage.setItem('token', token)
7
8 // Verify connection
9 await fetch(`${API_URL}/health`)
10
11 console.log('CustomAdapter initialized')
12 }
13})Examples
Example 1: PostgREST-Style API
1const db = createClient({
2 dbSpec: { schema: DBSchema },
3 adapter: (dbSpec) => new CustomAdapter(dbSpec, {
4 handlers: createRESTHandlers({
5 baseUrl: 'https://api.example.com',
6 headers: () => ({
7 'Authorization': `Bearer ${localStorage.getItem('token')}`,
8 'Prefer': 'return=representation'
9 })
10 })
11 })
12})
13
14// All standard queries work
15const { data } = await db
16 .from('products')
17 .eq('category', 'electronics')
18 .gt('price', 100)
19 .order('rating', { ascending: false })
20 .limit(20)
21 .select('id, name, price, rating')Example 2: Custom Query Parameters
For APIs with non-standard query formats:
1const db = createClient({
2 dbSpec: { schema: DBSchema },
3 adapter: (dbSpec) => new CustomAdapter(dbSpec, {
4 handlers: {
5 select: async (projection, ctx) => {
6 const params = new URLSearchParams()
7
8 // Custom filter format
9 for (const filter of ctx.state.filters) {
10 if (filter.type === 'eq') {
11 params.append(filter.column, String(filter.value))
12 } else if (filter.type === 'gt') {
13 params.append(`min_${filter.column}`, String(filter.value))
14 } else if (filter.type === 'in') {
15 filter.value.forEach(v => params.append(filter.column, String(v)))
16 }
17 }
18
19 // Custom ordering
20 if (ctx.state.order) {
21 const dir = ctx.state.order.ascending ? 'asc' : 'desc'
22 params.append('sort_by', ctx.state.order.column)
23 params.append('sort_dir', dir)
24 }
25
26 // Pagination
27 if (ctx.state.limit) {
28 params.append('page_size', String(ctx.state.limit))
29 }
30
31 const url = `${API_BASE}/${ctx.table}?${params.toString()}`
32 const response = await fetch(url)
33 const data = await response.json()
34
35 return { data, error: null }
36 },
37 // ... other handlers
38 }
39 })
40})Example 3: GraphQL Backend
CustomAdapter works with any protocol:
1const db = createClient({
2 dbSpec: { schema: DBSchema },
3 adapter: (dbSpec) => new CustomAdapter(dbSpec, {
4 handlers: {
5 select: async (projection, ctx) => {
6 const fields = projection?.split(',').map(f => f.trim()).join('\n ') || 'id'
7
8 const where = ctx.state.filters
9 .map(f => `${f.column}: ${JSON.stringify(f.value)}`)
10 .join(', ')
11
12 const query = `
13 query {
14 ${ctx.table}(where: { ${where} }) {
15 ${fields}
16 }
17 }
18 `
19
20 const response = await fetch('https://api.example.com/graphql', {
21 method: 'POST',
22 headers: { 'Content-Type': 'application/json' },
23 body: JSON.stringify({ query })
24 })
25
26 const { data } = await response.json()
27 return { data: data[ctx.table], error: null }
28 },
29 // ... other handlers
30 }
31 })
32})Example 4: Authentication Flow
Handle authentication in initialization:
1let authToken: string | null = null
2
3const db = createClient({
4 dbSpec: { schema: DBSchema },
5 adapter: (dbSpec) => new CustomAdapter(dbSpec, {
6 onInit: async () => {
7 const response = await fetch('https://api.example.com/auth', {
8 method: 'POST',
9 body: JSON.stringify({ apiKey: process.env.API_KEY })
10 })
11 const { token } = await response.json()
12 authToken = token
13 },
14 handlers: createRESTHandlers({
15 baseUrl: 'https://api.example.com',
16 headers: () => ({
17 'Authorization': `Bearer ${authToken}`
18 })
19 })
20 })
21})Example 5: Client-Side Filtering
If your API doesn't support certain operations, apply them client-side:
1select: async (projection, ctx) => {
2 // Separate supported and unsupported filters
3 const apiFilters = ctx.state.filters.filter(f => ['eq', 'gt', 'lt'].includes(f.type))
4 const clientFilters = ctx.state.filters.filter(f => !['eq', 'gt', 'lt'].includes(f.type))
5
6 // Fetch with API-supported filters
7 const url = buildURL(ctx.table, apiFilters)
8 const response = await fetch(url)
9 let data = await response.json()
10
11 // Apply unsupported filters client-side
12 for (const filter of clientFilters) {
13 if (filter.type === 'in') {
14 data = data.filter((row: any) => filter.value.includes(row[filter.column]))
15 } else if (filter.type === 'like') {
16 const pattern = filter.value.replace(/%/g, '')
17 data = data.filter((row: any) => row[filter.column]?.includes(pattern))
18 }
19 }
20
21 // Apply ordering client-side if needed
22 if (ctx.state.order) {
23 const { column, ascending = true } = ctx.state.order
24 data.sort((a: any, b: any) => {
25 return ascending
26 ? a[column] > b[column] ? 1 : -1
27 : a[column] < b[column] ? 1 : -1
28 })
29 }
30
31 return { data, error: null }
32}TypeScript Support
CustomAdapter is fully typed. Use TypeScript for better type safety:
1import type { CustomAdapterHandlers, FilterOp } from '@vibecode-db/client'
2
3const handlers: CustomAdapterHandlers = {
4 select: async (projection, ctx) => {
5 // ctx is typed as CustomAdapterContext
6 // TypeScript ensures correct return type
7 const response = await fetch(url)
8 return { data: await response.json(), error: null }
9 },
10 // ... other handlers
11}API Reference
CustomAdapter
1class CustomAdapter implements DatabaseAdapter {
2 constructor(dbSpec: DBSpec<any>, options: CustomAdapterOptions)
3}CustomAdapterOptions
1interface CustomAdapterOptions {
2 handlers: CustomAdapterHandlers // Required CRUD handlers
3 onInit?: () => Promise<void> | void // Optional initialization
4}createRESTHandlers
1function createRESTHandlers(config: RESTHandlerConfig): CustomAdapterHandlers
2
3interface RESTHandlerConfig {
4 baseUrl: string
5 formatFilter?: (filter: FilterOp) => string
6 headers?: () => Record<string, string> | Promise<Record<string, string>>
7 handleError?: (response: Response) => Promise<Error>
8}Handler Types
1type SelectHandler = (
2 projection: string | undefined,
3 ctx: CustomAdapterContext
4) => Promise<{ data: any; error: Error | null }>
5
6type InsertHandler = (
7 values: any | any[],
8 ctx: { table: string }
9) => Promise<{ data: any; error: Error | null }>
10
11type UpdateHandler = (
12 patch: Record<string, unknown>,
13 ctx: CustomAdapterContext
14) => Promise<{ data: any; error: Error | null }>
15
16type DeleteHandler = (
17 ctx: CustomAdapterContext
18) => Promise<{ data: null; error: Error | null }>Troubleshooting
Filters not working?
- Check your
formatFilterimplementation covers all filter types - Verify your API receives and processes the parameters correctly
- Add logging to inspect generated URLs
Authentication issues?
- Use
onInitto authenticate before any queries - Ensure
headersfunction returns valid, current tokens - Handle token refresh in your
headersfunction
Insert/update returns null?
- Confirm your API returns the modified data
- Some APIs require specific headers (e.g.,
Prefer: return=representation) - Check the response format matches expectations
Projection not working?
- Many APIs don't support field selection
- Consider fetching all fields and filtering client-side
- Or implement projection in request headers if your API supports it