superapp
BackendDatabases

Custom Providers

Build a Drizzle-compatible provider for any data source.

Chat in Claude

When your database is not one of the built-in providers, or when you need to connect a non-database source (REST APIs, edge runtimes, proprietary systems), build a custom provider following the Drizzle adapter pattern.

Drizzle Adapter Structure

Every Drizzle database adapter follows the same file structure. Your custom provider should mirror this pattern:

providers/
└── my-database/
    ├── driver.ts      # Connection initialization and public API
    ├── session.ts     # Session and query execution
    ├── migrator.ts    # Migration utilities (optional)
    └── index.ts       # Public exports

This is the same structure used by every adapter in the Drizzle ORM source -- from node-postgres to d1 to planetscale-serverless.

driver.ts

The driver file initializes the connection and returns a database instance. It wraps the underlying JavaScript driver:

import { IntegrationProvider, ConnectionConfig, DriverConnection } from '@superapp/backend'

export interface MyDatabaseConfig extends ConnectionConfig {
  connectionString: string
  poolSize?: number
}

export const myDatabaseProvider: IntegrationProvider = {
  type: 'my-database',

  capabilities: {
    read: true,
    write: true,
    transactions: true,
  },

  async connect(config: MyDatabaseConfig): Promise<DriverConnection> {
    // Initialize the native driver — same as Drizzle's drizzle() entry point
    const client = new MyDatabaseClient(config.connectionString, {
      max: config.poolSize ?? 10,
    })
    await client.connect()

    return {
      client,
      dialect: 'my-database',
    }
  },

  async disconnect(connection: DriverConnection): Promise<void> {
    await connection.client.end()
  },

  async introspect(connection: DriverConnection) {
    const tables = await connection.client.query(
      `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
    )
    // Return TableSchema[] — same shape as Drizzle's introspect output
    return Promise.all(
      tables.rows.map(async (t: any) => {
        const columns = await connection.client.query(
          `SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = $1`,
          [t.table_name]
        )
        return {
          name: t.table_name,
          columns: columns.rows.map((c: any) => ({
            name: c.column_name,
            type: c.data_type,
            nullable: c.is_nullable === 'YES',
          })),
        }
      })
    )
  },
}

session.ts

The session file manages query execution and transactions. In Drizzle, every adapter implements a Session class that wraps the driver's query method:

import { DriverConnection, QueryRequest, QueryResult } from '@superapp/backend'

export class MyDatabaseSession {
  constructor(private connection: DriverConnection) {}

  async execute(query: QueryRequest): Promise<QueryResult> {
    const result = await this.connection.client.query(query.sql, query.params)
    return {
      rows: result.rows,
      count: result.rowCount,
    }
  }

  async transaction<T>(fn: (tx: MyDatabaseSession) => Promise<T>): Promise<T> {
    const client = await this.connection.client.getConnection()
    try {
      await client.query('BEGIN')
      const tx = new MyDatabaseSession({ ...this.connection, client })
      const result = await fn(tx)
      await client.query('COMMIT')
      return result
    } catch (error) {
      await client.query('ROLLBACK')
      throw error
    } finally {
      client.release()
    }
  }
}

index.ts

The index file re-exports the public API, just like every Drizzle adapter:

export { myDatabaseProvider } from './driver'
export type { MyDatabaseConfig } from './driver'

Registering the Provider

Pass your custom provider in the providers array:

import { createEngine } from '@superapp/backend'
import { myDatabaseProvider } from './providers/my-database'

const engine = createEngine({
  providers: [myDatabaseProvider],
  connections: {
    main: {
      type: 'my-database',
      connectionString: process.env.MY_DB_URL!,
    },
  },
})

Example: Neon Serverless Provider

A real-world example following the Drizzle adapter pattern for Neon's serverless driver:

import { neon } from '@neondatabase/serverless'
import { IntegrationProvider } from '@superapp/backend'

export const neonProvider: IntegrationProvider = {
  type: 'neon',

  capabilities: { read: true, write: true, transactions: false },

  async connect(config) {
    const sql = neon(config.connectionString)
    return { client: sql, dialect: 'pg' }
  },

  async disconnect() {
    // Neon HTTP connections are stateless — no cleanup needed
  },

  async introspect(connection) {
    const tables = await connection.client(
      `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
    )
    return Promise.all(
      tables.map(async (t: any) => {
        const columns = await connection.client(
          `SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = '${t.table_name}'`
        )
        return {
          name: t.table_name,
          columns: columns.map((c: any) => ({
            name: c.column_name,
            type: c.data_type,
            nullable: c.is_nullable === 'YES',
          })),
        }
      })
    )
  },
}

// Usage
const engine = createEngine({
  providers: [neonProvider],
  connections: {
    main: { type: 'neon', connectionString: process.env.NEON_URL! },
  },
})

Example: Cloudflare D1 Provider

Following the D1 adapter pattern:

import { IntegrationProvider } from '@superapp/backend'

export const d1Provider: IntegrationProvider = {
  type: 'd1',

  capabilities: { read: true, write: true, transactions: true },

  async connect(config) {
    // D1 binding is passed from the Cloudflare Workers environment
    return { client: config.binding, dialect: 'sqlite' }
  },

  async disconnect() {
    // D1 bindings are managed by the Workers runtime
  },

  async introspect(connection) {
    const result = await connection.client
      .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
      .all()

    return Promise.all(
      result.results.map(async (t: any) => {
        const columns = await connection.client
          .prepare(`PRAGMA table_info('${t.name}')`)
          .all()
        return {
          name: t.name,
          columns: columns.results.map((c: any) => ({
            name: c.name,
            type: c.type,
            nullable: c.notnull === 0,
            primaryKey: c.pk === 1,
          })),
        }
      })
    )
  },
}

// Usage in a Cloudflare Worker
export default {
  async fetch(request: Request, env: Env) {
    const engine = createEngine({
      providers: [d1Provider],
      connections: {
        main: { type: 'd1', binding: env.DB },
      },
    })
    // ...
  },
}

Non-Database Providers

The same adapter pattern works for non-database sources. Instead of SQL, the session translates queries into API calls:

import { IntegrationProvider } from '@superapp/backend'

const httpProvider: IntegrationProvider = {
  type: 'http-api',

  capabilities: { read: true, write: false, transactions: false },

  async connect(config) {
    // Validate the API is reachable
    const res = await fetch(`${config.baseUrl}/health`, {
      headers: { Authorization: `Bearer ${config.apiKey}` },
    })
    if (!res.ok) throw new Error(`API unreachable: ${res.status}`)
    return { client: config, dialect: 'http' }
  },

  async disconnect() {},

  async introspect(connection) {
    const res = await fetch(`${connection.client.baseUrl}/schema`, {
      headers: { Authorization: `Bearer ${connection.client.apiKey}` },
    })
    return res.json()
  },
}

IntegrationProvider Interface

interface IntegrationProvider {
  type: string
  capabilities: {
    read: boolean
    write: boolean
    transactions: boolean
  }
  connect: (config: ConnectionConfig) => Promise<DriverConnection>
  disconnect: (connection: DriverConnection) => Promise<void>
  introspect: (connection: DriverConnection) => Promise<TableSchema[]>
}

interface TableSchema {
  name: string
  columns: {
    name: string
    type: string
    nullable: boolean
    primaryKey?: boolean
  }[]
}

Client queries work the same regardless of provider type:

const contacts = await db.crm.contacts.findMany({
  select: ['id', 'name', 'email'],
  where: { status: { $eq: 'active' } },
  limit: 50,
})

On this page