Skip to content

🧰 A modular adapter layer for working with any database (Drizzle, Prisma, MongoDB, Kysely & more)

License

Notifications You must be signed in to change notification settings

productdevbook/unadapter

Repository files navigation

unadapter

A universal adapter interface for connecting various databases and ORMs with a standardized API.

Work In Progress

npm version npm downloads bundle License CI Status Lines Statements Functions Branches

πŸš€ Features

  • πŸ”„ 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

πŸ“š Table of Contents

🌟 Overview

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.

Development Roadmap

  • 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

Test Coverage

Lines Statements Functions Branches

CI Status JSDocs

πŸ“¦ Installation

# 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.

🧩 Available Adapters

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

πŸš€ Getting Started

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
  }
})

Database-Specific Adapters

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' }
  }
})

πŸ” API Reference

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
  }
}

🀝 Contributing

Contributions are welcome! Feel free to open issues or submit pull requests to help improve unadapter.

Development Setup
  1. Clone the repository:

    git clone https://github.com/productdevbook/unadapter.git
    cd unadapter
  2. Install dependencies:

    pnpm install
  3. Run tests:

    pnpm test
  4. Build the project:

    pnpm build

πŸ™ Credits

This project draws inspiration and core concepts from:

  • better-auth - The original adapter architecture that inspired this project

πŸ“ License

See the LICENSE file for details.

unadapter is a work in progress. Stay tuned for updates!

About

🧰 A modular adapter layer for working with any database (Drizzle, Prisma, MongoDB, Kysely & more)

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published