@contract-kit/provider-drizzle-turso
v1.0.0
Published
Drizzle ORM + Turso/libSQL provider for contract-kit - adds typed DbPort using drizzle-orm
Maintainers
Readme
@contract-kit/provider-drizzle-turso
Drizzle ORM + Turso/libSQL provider for Contract Kit. Adds a typed database port to your hexagonal application using Drizzle ORM with Turso's libSQL.
Features
- 🎯 Factory-based: Create providers with your schema at the call site
- 🔒 Type-safe: Full TypeScript inference from your Drizzle schema
- 🌐 Turso-ready: Works with Turso cloud or local libSQL
- 🏗️ Schema-agnostic: You control where your schema files live
- 🔌 Clean separation: Runtime provider is separate from build-time CLI config
Installation
bun add @contract-kit/provider-drizzle-turso drizzle-orm @libsql/clientTypeScript requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Setup
1. Define your schema
Create your Drizzle schema file wherever makes sense for your app. Framework
usage keeps it under infra/db/:
// infra/db/schema.ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
export const todos = sqliteTable("todos", {
id: text("id").primaryKey(),
title: text("title").notNull(),
completed: text("completed").notNull().default("false"),
});2. Configure Drizzle CLI (build-time)
Create drizzle.config.ts in your app root for the Drizzle CLI:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./infra/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.TURSO_DB_URL!,
},
});3. Create the provider (runtime)
Import your schema and create the provider:
// server/providers.ts
import { createDrizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import * as schema from "@/infra/db/schema";
const drizzleTursoProvider = createDrizzleTursoProvider({ schema });
export const providers = [drizzleTursoProvider];4. Type your ports
Define your app's ports type:
// ports/index.ts
import type { DbPort } from "@contract-kit/provider-drizzle-turso";
import * as schema from "@/infra/db/schema";
export type AppPorts = {
db: DbPort<typeof schema>;
// other ports...
};5. Wire it up in your server
// server/index.ts
import { createNextServer, defineRoutes } from "@contract-kit/next";
import { ports } from "@/infra/ports";
import { providers } from "./providers";
export const server = await createNextServer({
ports,
providers,
createContext: ({ ports }) => ({ ports }),
routes: defineRoutes([
// ... your routes
]),
mapUnhandledError: () => ({
status: 500,
body: {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
},
}),
});6. Use in your use cases
// use-cases/todos/list-todos.ts
import { z } from "zod";
import * as schema from "@/infra/db/schema";
import { useCase } from "../builder";
export const listTodos = useCase
.query("todos.list")
.input(z.object({}))
.output(z.array(z.object({ id: z.string(), title: z.string() })))
.run(async ({ ctx }) => {
const db = ctx.ports.db.db; // LibSQLDatabase<typeof schema> - fully typed!
const rows = await db.select().from(schema.todos);
return rows;
});Devtools
When @contract-kit/devtools is registered before this provider, Drizzle query
logging is recorded automatically under the db watcher. Events include the SQL
query text, parameter count, port name, and provider name. Parameter values are
redacted by default.
import { createDevtoolsProvider } from "@contract-kit/devtools";
import { createDrizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
export const providers = [
createDevtoolsProvider(),
createDrizzleTursoProvider({ schema }),
];Unit of work
Use createDrizzleTursoUnitOfWork(...) when a use case needs a real database
transaction. Your app still owns the repository interfaces; the helper only
starts a Drizzle transaction, gives you the transaction client, and optionally
flushes recorded domain events after commit.
// ports/index.ts
import type {
DomainEventRecorderPort,
UnitOfWorkPort,
} from "@contract-kit/ports";
import type { DbPort } from "@contract-kit/provider-drizzle-turso";
import * as schema from "@/infra/db/schema";
import type { TodoRepository } from "./todo-repository";
export type AppTransactionPorts = {
todos: TodoRepository;
events: DomainEventRecorderPort;
};
export type AppPorts = {
db: DbPort<typeof schema>;
todos: TodoRepository;
uow: UnitOfWorkPort<AppTransactionPorts>;
};// infra/db/todo-repository.ts
import type { DrizzleTursoDatabase } from "@contract-kit/provider-drizzle-turso";
import * as schema from "./schema";
export function createDrizzleTodoRepository(
db: DrizzleTursoDatabase<typeof schema>,
): TodoRepository {
return {
async create(input) {
const [row] = await db.insert(schema.todos).values(input).returning();
return row;
},
};
}// server/index.ts
import { createDrizzleTursoUnitOfWork } from "@contract-kit/provider-drizzle-turso";
import { createDrizzleTodoRepository } from "@/infra/db/todo-repository";
createContext: async ({ ports }) => {
const todos = createDrizzleTodoRepository(ports.db.db);
return {
ports: {
...ports,
todos,
uow: createDrizzleTursoUnitOfWork({
db: ports.db.db,
eventBus: ports.eventBus,
createTransactionPorts: (tx, events) => ({
todos: createDrizzleTodoRepository(tx),
events,
}),
}),
},
};
};Inside a use case, call transaction-scoped repositories through tx and record
events through the transaction-local recorder:
const todo = await ctx.ports.uow.transaction(async (tx) => {
const created = await tx.todos.create(input);
tx.events.record(todoCreated, { todoId: created.id });
return created;
});Recorded events are published only after Drizzle commits. If the transaction
throws, events are not flushed. If event publishing fails after commit,
transaction(...) rejects but the database transaction is already committed;
use an outbox when events or jobs need durable delivery guarantees.
Configuration
The provider reads configuration from environment variables with the TURSO_ prefix:
| Variable | Required | Description |
|----------|----------|-------------|
| TURSO_DB_URL | Yes | Turso/libSQL database URL (e.g., libsql://your-db.turso.io or file:local.db) |
| TURSO_DB_AUTH_TOKEN | No | Turso auth token (required for cloud databases, optional for local) |
Example .env
# For Turso cloud
TURSO_DB_URL=libsql://my-app-db.turso.io
TURSO_DB_AUTH_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI...
# For local development
TURSO_DB_URL=file:local.dbAdvanced usage
Multiple databases
You can create multiple database providers with different port names:
import * as mainSchema from "@/infra/db/schema";
import * as analyticsSchema from "@/infra/db/analytics-schema";
import { createDrizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
export const mainDbProvider = createDrizzleTursoProvider({
schema: mainSchema,
portName: "db", // default
});
export const analyticsDbProvider = createDrizzleTursoProvider({
schema: analyticsSchema,
portName: "analyticsDb",
});
// Then in your ports type:
export type AppPorts = {
db: DbPort<typeof mainSchema>;
analyticsDb: DbPort<typeof analyticsSchema>;
};Accessing the Drizzle instance
The DbPort exposes the typed Drizzle database instance:
import { eq } from "drizzle-orm";
const db = ctx.ports.db.db; // LibSQLDatabase<TSchema>
// All Drizzle operations are available:
await db.select().from(schema.todos);
await db.insert(schema.todos).values({ id: "1", title: "Hello" });
await db.update(schema.todos).set({ title: "Updated" }).where(eq(schema.todos.id, "1"));
await db.delete(schema.todos).where(eq(schema.todos.id, "1"));
// Prefer createDrizzleTursoUnitOfWork(...) for application workflows that
// should run through repository ports and flush domain events after commit.
// Access the underlying libSQL client for advanced operations:
const client = ctx.ports.db.client;
const result = await client.execute("SELECT * FROM todos WHERE id = ?", ["1"]);Key design principles
Runtime vs. build-time separation
This provider follows a clean separation of concerns:
Build-time (Drizzle CLI): Configured via
drizzle.config.ts- Used for generating migrations
- Used for introspecting the database
- Lives in your app repository
Runtime (Provider): Configured via factory function
- Used for connecting to the database at runtime
- Used for executing queries in your use cases
- Gets the schema from your imports
Schema location independence
The provider does not care where your schema file lives. You:
- Define your schema file wherever makes sense (
infra/db/schema.ts,db/schema.ts, etc.) - Import it in your app:
import * as schema from "@/infra/db/schema" - Pass it to the factory:
createDrizzleTursoProvider({ schema })
This keeps the provider flexible and your app in control of its structure.
API reference
DbPort<TSchema>
The port interface exposed on ctx.ports.db:
interface DbPort<TSchema extends Record<string, any> = any> {
db: LibSQLDatabase<TSchema>;
client: Client;
}db: The typed Drizzle database instance for ORM operationsclient: The underlying libSQL client for advanced operations not covered by Drizzle
createDrizzleTursoProvider<TSchema>(options)
Factory function to create a Drizzle Turso provider.
Parameters:
options.schema(required): Your Drizzle schema objectoptions.portName(optional): Port name, defaults to"db"
Returns: A provider that can be registered with createServer, createNextServer, or another Contract Kit server adapter.
Example:
const provider = createDrizzleTursoProvider({
schema: mySchema,
portName: "db", // optional
});createDrizzleTursoUnitOfWork<TSchema, TxPorts>(options)
Factory function to create a transaction-backed UnitOfWorkPort.
Parameters:
options.db(required): The rootLibSQLDatabase<TSchema>instanceoptions.createTransactionPorts(required): Factory that receives the Drizzle transaction client and event recorderoptions.eventBus(optional): Event bus used to flush recorded events after commitoptions.transactionConfig(optional): Drizzle transaction configuration
Returns: A UnitOfWorkPort<TxPorts> that runs work inside
db.transaction(...).
License
MIT
