Custom Providers
Build a Drizzle-compatible provider for any data source.
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 exportsThis 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,
})