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=10

Quick 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.filters to 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.filters to 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 BuilderState ObjectType
.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 formatFilter implementation covers all filter types
  • Verify your API receives and processes the parameters correctly
  • Add logging to inspect generated URLs

Authentication issues?

  • Use onInit to authenticate before any queries
  • Ensure headers function returns valid, current tokens
  • Handle token refresh in your headers function

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