A universal adapter interface for connecting various databases and ORMs with a standardized API.
- π Standardized interface for common database operations (create, read, update, delete)
- π‘οΈ Type-safe operations
- π Support for complex queries and transformations
- π Database-agnostic application code
- π Easy switching between different database providers
- πΊοΈ Custom field mapping
- π Support for various data types across different database systems
- ποΈ Fully customizable schema definition
unadapter provides a consistent interface for database operations, allowing you to switch between different database solutions without changing your application code. This is particularly useful for applications that need database-agnostic operations or might need to switch database providers in the future.
π§ Development Status
This project is based on the adapter architecture from better-auth and is being developed to provide a standalone, ESM-compatible adapter solution that can be used across various open-source projects.
- Initial adapter architecture
- Basic adapters implementation
- Comprehensive documentation
- Performance optimizations
- Additional adapter types
- Integration examples
- Complete abstraction from better-auth and compatibility with all software systems
# Using pnpm
pnpm add unadapter
# Using npm
npm install unadapter
# Using yarn
yarn add unadapter
You'll also need to install the specific database driver or ORM you plan to use.
Adapter | Description | Status |
---|---|---|
Memory Adapter | In-memory adapter ideal for development and testing | β Ready |
Prisma Adapter | For Prisma ORM | β Ready |
MongoDB Adapter | For MongoDB | β Ready |
Drizzle Adapter | For Drizzle ORM | β Ready |
Kysely Adapter | For Kysely SQL query builder | β Ready |
Basic Usage
import type { PluginSchema } from 'unadapter/types'
import { createAdapter, createTable, mergePluginSchemas } from 'unadapter'
import { memoryAdapter } from 'unadapter/memory'
// Create an in-memory database for testing
const db = {
user: [],
session: []
}
// Define a consistent options interface that can be reused
interface CustomOptions {
appName?: string
plugins?: {
schema?: PluginSchema
}[]
user?: {
fields?: {
name?: string
email?: string
emailVerified?: string
image?: string
createdAt?: string
}
}
}
const tables = createTable<CustomOptions>((options) => {
const { user, ...pluginTables } = mergePluginSchemas<CustomOptions>(options) || {}
return {
user: {
modelName: 'user',
fields: {
name: {
type: 'string',
required: true,
fieldName: options?.user?.fields?.name || 'name',
sortable: true,
},
email: {
type: 'string',
unique: true,
required: true,
fieldName: options?.user?.fields?.email || 'email',
sortable: true,
},
emailVerified: {
type: 'boolean',
defaultValue: () => false,
required: true,
fieldName: options?.user?.fields?.emailVerified || 'emailVerified',
},
createdAt: {
type: 'date',
defaultValue: () => new Date(),
required: true,
fieldName: options?.user?.fields?.createdAt || 'createdAt',
},
updatedAt: {
type: 'date',
defaultValue: () => new Date(),
required: true,
fieldName: options?.user?.fields?.updatedAt || 'updatedAt',
},
...user?.fields,
...options?.user?.fields,
}
}
}
})
const adapter = createAdapter(tables, {
database: memoryAdapter(
db,
{}
),
plugins: [] // Optional plugins
})
// Now you can use the adapter to perform database operations
const user = await adapter.create({
model: 'user',
data: {
name: 'John Doe',
email: 'john@example.com',
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date()
}
})
// Find the user
const foundUsers = await adapter.findMany({
model: 'user',
where: [
{
field: 'email',
value: 'john@example.com',
operator: 'eq',
}
]
})
Using Custom Schema and Plugins
import type { PluginSchema } from 'unadapter/types'
import { createAdapter, createTable, mergePluginSchemas } from 'unadapter'
import { memoryAdapter } from 'unadapter/memory'
// Create an in-memory database for testing
const db = {
users: [],
products: []
}
// Using the same pattern for CustomOptions
interface CustomOptions {
appName?: string
plugins?: {
schema?: PluginSchema
}[]
user?: {
fields?: {
fullName?: string
email?: string
isActive?: string
}
}
product?: {
fields?: {
title?: string
price?: string
ownerId?: string
}
}
}
const tables = createTable<CustomOptions>((options) => {
const { user, product, ...pluginTables } = mergePluginSchemas<CustomOptions>(options) || {}
return {
user: {
modelName: 'users', // The actual table/collection name in your database
fields: {
fullName: {
type: 'string',
required: true,
fieldName: options?.user?.fields?.fullName || 'full_name',
sortable: true,
},
email: {
type: 'string',
unique: true,
required: true,
fieldName: options?.user?.fields?.email || 'email_address',
},
isActive: {
type: 'boolean',
fieldName: options?.user?.fields?.isActive || 'is_active',
defaultValue: () => true,
},
createdAt: {
type: 'date',
fieldName: 'created_at',
defaultValue: () => new Date(),
},
...user?.fields,
...options?.user?.fields,
}
},
product: {
modelName: 'products',
fields: {
title: {
type: 'string',
required: true,
fieldName: options?.product?.fields?.title || 'title',
},
price: {
type: 'number',
required: true,
fieldName: options?.product?.fields?.price || 'price',
},
ownerId: {
type: 'string',
references: {
model: 'user',
field: 'id',
onDelete: 'cascade',
},
required: true,
fieldName: options?.product?.fields?.ownerId || 'owner_id',
},
...product?.fields,
...options?.product?.fields,
}
}
}
})
// User profile plugin schema
const userProfilePlugin = {
schema: {
user: {
modelName: 'user',
fields: {
bio: {
type: 'string',
required: false,
fieldName: 'bio',
},
location: {
type: 'string',
required: false,
fieldName: 'location',
}
}
}
}
}
const adapter = createAdapter(tables, {
database: memoryAdapter(
db,
{}
),
plugins: [userProfilePlugin],
})
// Now you can use the adapter with your custom schema
const user = await adapter.create({
model: 'user',
data: {
fullName: 'John Doe',
email: 'john@example.com',
bio: 'Software developer',
location: 'New York'
}
})
// Create a product linked to the user
const product = await adapter.create({
model: 'product',
data: {
title: 'Awesome Product',
price: 99.99,
ownerId: user.id
}
})
MongoDB Adapter Example
import type { PluginSchema } from 'unadapter/types'
import { createAdapter, createTable, mergePluginSchemas } from 'unadapter'
import { MongoClient } from 'mongodb'
import { mongodbAdapter } from 'unadapter/mongodb'
// Create a database client
const client = new MongoClient('mongodb://localhost:27017')
await client.connect()
const db = client.db('myDatabase')
// Using the same pattern for CustomOptions
interface CustomOptions {
appName?: string
plugins?: {
schema?: PluginSchema
}[]
user?: {
fields?: {
name?: string
email?: string
settings?: string
}
}
}
const tables = createTable<CustomOptions>((options) => {
const { user, ...pluginTables } = mergePluginSchemas<CustomOptions>(options) || {}
return {
user: {
modelName: 'users',
fields: {
name: {
type: 'string',
required: true,
fieldName: options?.user?.fields?.name || 'name',
},
email: {
type: 'string',
required: true,
unique: true,
fieldName: options?.user?.fields?.email || 'email',
},
settings: {
type: 'json',
required: false,
fieldName: options?.user?.fields?.settings || 'settings',
},
createdAt: {
type: 'date',
defaultValue: () => new Date(),
fieldName: 'createdAt',
},
...user?.fields,
...options?.user?.fields,
}
}
}
})
// Initialize the adapter
const adapter = createAdapter(tables, {
database: mongodbAdapter(
db,
{
useNumberId: false
}
),
plugins: []
})
// Use the adapter
const user = await adapter.create({
model: 'user',
data: {
name: 'Jane Doe',
email: 'jane@example.com',
settings: { theme: 'dark', notifications: true }
}
})
Prisma Adapter Example
import type { PluginSchema } from 'unadapter/types'
import { createAdapter, createTable, mergePluginSchemas } from 'unadapter'
import { PrismaClient } from '@prisma/client'
import { prismaAdapter } from 'unadapter/prisma'
// Initialize Prisma client
const prisma = new PrismaClient()
// Using the same pattern for CustomOptions
interface CustomOptions {
appName?: string
plugins?: {
schema?: PluginSchema
}[]
user?: {
fields?: {
name?: string
email?: string
profile?: string
}
}
post?: {
fields?: {
title?: string
content?: string
authorId?: string
}
}
}
const tables = createTable<CustomOptions>((options) => {
const { user, post, ...pluginTables } = mergePluginSchemas<CustomOptions>(options) || {}
return {
user: {
modelName: 'User', // Match your Prisma model name (case-sensitive)
fields: {
name: {
type: 'string',
required: true,
fieldName: options?.user?.fields?.name || 'name',
},
email: {
type: 'string',
required: true,
unique: true,
fieldName: options?.user?.fields?.email || 'email',
},
profile: {
type: 'json',
required: false,
fieldName: options?.user?.fields?.profile || 'profile',
},
createdAt: {
type: 'date',
defaultValue: () => new Date(),
fieldName: 'createdAt',
},
...user?.fields,
...options?.user?.fields,
}
},
post: {
modelName: 'Post',
fields: {
title: {
type: 'string',
required: true,
fieldName: options?.post?.fields?.title || 'title',
},
content: {
type: 'string',
required: false,
fieldName: options?.post?.fields?.content || 'content',
},
published: {
type: 'boolean',
defaultValue: () => false,
fieldName: 'published',
},
authorId: {
type: 'string',
references: {
model: 'user',
field: 'id',
onDelete: 'cascade',
},
required: true,
fieldName: options?.post?.fields?.authorId || 'authorId',
},
...post?.fields,
...options?.post?.fields,
}
}
}
})
// Initialize the adapter
const adapter = createAdapter(tables, {
database: prismaAdapter(
prisma,
{
provider: 'postgresql',
debugLogs: true,
usePlural: false
}
),
plugins: []
})
// Use the adapter
const user = await adapter.create({
model: 'user',
data: {
name: 'John Smith',
email: 'john.smith@example.com',
profile: { bio: 'Software developer', location: 'New York' }
}
})
Drizzle Adapter Example
import type { PluginSchema } from 'unadapter/types'
import { createAdapter, createTable, mergePluginSchemas } from 'unadapter'
import { sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/node-postgres'
import { pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'
import { drizzleAdapter } from 'unadapter/drizzle'
import 'dotenv/config'
// Define your Drizzle schema
export const role = pgTable(
'role',
{
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
name: varchar('name', { length: 255 }).notNull(),
key: varchar('key', { length: 255 }).notNull().unique(),
type: varchar('type', { length: 255 }).notNull().default('user'),
description: varchar('description', { length: 500 }).notNull(),
userId: uuid('user_id').notNull(),
permissions: text('permissions')
.notNull()
.default('0'),
updatedAt: timestamp('updated_at').notNull().default(sql`now()`),
createdAt: timestamp('created_at').notNull().default(sql`now()`),
},
)
// Using the same pattern for CustomOptions
interface CustomOptions {
appName?: string
plugins?: {
schema?: PluginSchema
}[]
role?: {
fields?: {
name?: string
description?: string
key?: string
permissions?: string
userId?: string
}
}
}
const tables = createTable<CustomOptions>((options) => {
const { user, role, ...pluginTables } = mergePluginSchemas<CustomOptions>(options) || {}
return {
role: {
modelName: 'role',
fields: {
name: {
type: 'string',
required: true,
fieldName: options?.role?.fields?.name || 'name',
},
description: {
type: 'string',
required: true,
fieldName: options?.role?.fields?.description || 'description',
},
key: {
type: 'string',
required: true,
fieldName: options?.role?.fields?.key || 'key',
},
permissions: {
type: 'string',
required: true,
fieldName: options?.role?.fields?.permissions || 'permissions',
},
userId: {
type: 'string',
required: true,
references: {
model: 'user',
field: 'id',
onDelete: 'cascade',
},
fieldName: options?.role?.fields?.userId || 'user_id',
},
createdAt: {
type: 'date',
required: true,
defaultValue: new Date(),
},
updatedAt: {
type: 'date',
required: true,
defaultValue: new Date(),
},
...role?.fields,
...options?.role?.fields,
},
},
}
})
// Initialize the adapter with the Drizzle schema
const adapter = createAdapter(tables, {
database: drizzleAdapter(
drizzle(process.env.DATABASE_URL!),
{
provider: 'pg',
debugLogs: true,
schema: {
role,
},
},
),
plugins: [], // Optional plugins
})
// Use the adapter
const role = await adapter.create({
model: 'role',
data: {
name: 'Test Role',
description: 'This is a test role',
key: 'test_role',
permissions: 'read,write',
userId: '8eea9d01-6c73-4933-bb0f-811cb7d4a862',
createdAt: new Date(),
updatedAt: new Date(),
},
})
Kysely Adapter Example
import type { PluginSchema } from 'unadapter/types'
import { createAdapter, createTable, mergePluginSchemas } from 'unadapter'
import { Kysely, PostgresDialect } from 'kysely'
import pg from 'pg'
import { kyselyAdapter } from 'unadapter/kysely'
// Create PostgreSQL connection pool
const pool = new pg.Pool({
host: 'localhost',
database: 'mydatabase',
user: 'myuser',
password: 'mypassword'
})
// Initialize Kysely with PostgreSQL dialect
const db = new Kysely({
dialect: new PostgresDialect({ pool })
})
// Using the same pattern for CustomOptions
interface CustomOptions {
appName?: string
plugins?: {
schema?: PluginSchema
}[]
user?: {
fields?: {
name?: string
email?: string
active?: string
meta?: string
}
}
article?: {
fields?: {
title?: string
content?: string
authorId?: string
}
}
}
const tables = createTable<CustomOptions>((options) => {
const { user, article, ...pluginTables } = mergePluginSchemas<CustomOptions>(options) || {}
return {
user: {
modelName: 'users',
fields: {
name: {
type: 'string',
required: true,
fieldName: options?.user?.fields?.name || 'name',
},
email: {
type: 'string',
required: true,
unique: true,
fieldName: options?.user?.fields?.email || 'email',
},
active: {
type: 'boolean',
defaultValue: () => true,
fieldName: options?.user?.fields?.active || 'is_active',
},
meta: {
type: 'json',
required: false,
fieldName: options?.user?.fields?.meta || 'meta_data',
},
createdAt: {
type: 'date',
defaultValue: () => new Date(),
fieldName: 'created_at',
},
...user?.fields,
...options?.user?.fields,
}
},
article: {
modelName: 'articles',
fields: {
title: {
type: 'string',
required: true,
fieldName: options?.article?.fields?.title || 'title',
},
content: {
type: 'string',
required: true,
fieldName: options?.article?.fields?.content || 'content',
},
authorId: {
type: 'string',
references: {
model: 'user',
field: 'id',
onDelete: 'cascade',
},
required: true,
fieldName: options?.article?.fields?.authorId || 'author_id',
},
tags: {
type: 'array',
required: false,
fieldName: 'tags',
},
publishedAt: {
type: 'date',
required: false,
fieldName: 'published_at',
},
...article?.fields,
...options?.article?.fields,
}
}
}
})
// Initialize the adapter
const adapter = createAdapter(tables, {
database: kyselyAdapter(
db,
{
defaultSchema: 'public'
}
),
plugins: []
})
// Use the adapter
const user = await adapter.create({
model: 'user',
data: {
name: 'Robert Chen',
email: 'robert@example.com',
meta: { interests: ['programming', 'reading'], location: 'San Francisco' }
}
})
Adapter Interface
All adapters implement the following interface:
interface Adapter {
// Create a new record
create<T>({
model: string,
data: Omit<T, 'id'>,
select?: string[]
}): Promise<T>;
// Find multiple records
findMany<T>({
model: string,
where?: Where[],
limit?: number,
sortBy?: {
field: string,
direction: 'asc' | 'desc'
},
offset?: number
}): Promise<T[]>;
// Update a record
update<T>({
model: string,
where: Where[],
update: Record<string, any>
}): Promise<T | null>;
// Update multiple records
updateMany({
model: string,
where: Where[],
update: Record<string, any>
}): Promise<number>;
// Delete a record
delete({
model: string,
where: Where[]
}): Promise<void>;
// Delete multiple records
deleteMany({
model: string,
where: Where[]
}): Promise<number>;
// Count records
count({
model: string,
where?: Where[]
}): Promise<number>;
}
Where Clause Interface
The Where
interface is used for filtering records:
interface Where {
field: string
value?: any
operator?: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'contains' | 'starts_with' | 'ends_with'
connector?: 'AND' | 'OR'
}
Field Types and Attributes
When defining your schema, you can use the following field types and attributes:
interface FieldAttribute {
// The type of the field
type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'array'
// Whether this field is required
required?: boolean
// Whether this field should be unique
unique?: boolean
// The actual column/field name in the database
fieldName?: string
// Whether this field can be sorted
sortable?: boolean
// Default value function
defaultValue?: () => any
// Reference to another model (for foreign keys)
references?: {
model: string
field: string
onDelete?: 'cascade' | 'set null' | 'restrict'
}
// Custom transformations
transform?: {
input?: (value: any) => any
output?: (value: any) => any
}
}
Contributions are welcome! Feel free to open issues or submit pull requests to help improve unadapter.
Development Setup
-
Clone the repository:
git clone https://github.com/productdevbook/unadapter.git cd unadapter
-
Install dependencies:
pnpm install
-
Run tests:
pnpm test
-
Build the project:
pnpm build
This project draws inspiration and core concepts from:
- better-auth - The original adapter architecture that inspired this project
See the LICENSE file for details.
unadapter is a work in progress. Stay tuned for updates!