Custom Providers
Build a provider for any data source.
When your data source is not a database DuckDB can attach natively -- REST APIs, GraphQL endpoints, edge runtimes, or proprietary systems -- build a custom provider.
import { createEngine, CustomIntegrationProvider } from '@superapp/backend'
const httpProvider: CustomIntegrationProvider = {
type: 'http-api',
capabilities: { read: true, write: false, transactions: false },
configSchema: {
baseUrl: { type: 'string', required: true },
apiKey: { type: 'string', required: true, secret: true },
},
async testConnection(config) {
const res = await fetch(`${config.baseUrl}/health`)
return res.ok
},
async introspect(config) {
const res = await fetch(`${config.baseUrl}/schema`, {
headers: { Authorization: `Bearer ${config.apiKey}` },
})
return res.json()
},
async execute(config, query) {
const res = await fetch(`${config.baseUrl}/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(query),
})
return res.json()
},
}
const engine = createEngine({
integrations: [httpProvider],
connections: {
crm: { type: 'http-api', baseUrl: 'https://api.crm.com', apiKey: process.env.CRM_API_KEY! },
},
})When to Use Custom Providers
- REST APIs -- wrap a third-party API as queryable tables
- Edge runtimes -- environments where DuckDB extensions cannot be loaded
- Proprietary databases -- systems without a DuckDB scanner extension
- Data virtualization -- combine API data with SQL databases in a single query layer
CustomIntegrationProvider Interface
interface CustomIntegrationProvider {
type: string
capabilities: {
read: boolean
write: boolean
transactions: boolean
}
configSchema: Record<string, ConfigField>
testConnection: (config: Record<string, unknown>) => Promise<boolean>
introspect: (config: Record<string, unknown>) => Promise<TableSchema[]>
execute: (config: Record<string, unknown>, query: QueryRequest) => Promise<QueryResult>
}
interface ConfigField {
type: 'string' | 'number' | 'boolean'
required?: boolean
secret?: boolean // hidden in admin UI, encrypted at rest
default?: unknown
description?: string
}
interface TableSchema {
name: string
columns: {
name: string
type: string
nullable: boolean
primaryKey?: boolean
}[]
}Config Schema
The configSchema object defines what fields appear in the admin UI when adding a connection of this type. Fields marked secret: true are encrypted with AES-256-GCM and never displayed after initial entry.
configSchema: {
baseUrl: {
type: 'string',
required: true,
description: 'Base URL of the API',
},
apiKey: {
type: 'string',
required: true,
secret: true,
description: 'API key for authentication',
},
timeout: {
type: 'number',
default: 30000,
description: 'Request timeout in milliseconds',
},
}testConnection
Called when a user adds or edits a connection in the admin UI. Return true if the connection is valid, or throw an error with a descriptive message:
async testConnection(config) {
const res = await fetch(`${config.baseUrl}/health`, {
headers: { Authorization: `Bearer ${config.apiKey}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) {
throw new Error(`API returned ${res.status}: ${res.statusText}`)
}
return true
}introspect
Returns the schema of all available tables. The engine calls this on startup and when the admin clicks "Refresh Schema":
async introspect(config) {
const res = await fetch(`${config.baseUrl}/schema`, {
headers: { Authorization: `Bearer ${config.apiKey}` },
})
const data = await res.json()
return data.resources.map((r: any) => ({
name: r.name,
columns: r.fields.map((f: any) => ({
name: f.name,
type: mapType(f.type),
nullable: f.nullable ?? true,
primaryKey: f.name === 'id',
})),
}))
}execute
Translates a QueryRequest into provider-specific calls and returns results:
async execute(config, query) {
const url = new URL(`${config.baseUrl}/${query.table}`)
if (query.where) {
for (const [key, condition] of Object.entries(query.where)) {
url.searchParams.set(`filter[${key}]`, String(Object.values(condition)[0]))
}
}
if (query.limit) url.searchParams.set('limit', String(query.limit))
if (query.offset) url.searchParams.set('offset', String(query.offset))
const res = await fetch(url, {
headers: { Authorization: `Bearer ${config.apiKey}` },
})
const data = await res.json()
return {
rows: data.items,
count: data.total,
}
}Registering the Provider
Pass your custom provider in the integrations array alongside native providers:
import { postgresProvider } from '@superapp/backend/integrations/postgres'
const engine = createEngine({
integrations: [postgresProvider, httpProvider],
connections: {
main: { type: 'postgres', url: process.env.PG_URL! },
crm: { type: 'http-api', baseUrl: 'https://api.crm.com', apiKey: process.env.CRM_API_KEY! },
},
})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,
})