Skip to content

Latest commit

 

History

History
1047 lines (932 loc) · 25 KB

README.md

File metadata and controls

1047 lines (932 loc) · 25 KB

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!