Drizzle ORM + Turso/libSQL provider for Beignet applications.
The provider installs a typed database port backed by Drizzle ORM and Turso's libSQL client. Your application still owns the schema, repository interfaces, and migration workflow.
bun add @beignet/provider-drizzle-turso drizzle-orm @libsql/client
Create your Drizzle schema files wherever makes sense for your app. Framework
usage keeps them under infra/db/schema/ so larger apps can split schema by
feature:
// infra/db/schema/todos.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const todos = sqliteTable("todos", {
id: text("id").primaryKey(),
title: text("title").notNull(),
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull(),
});
// infra/db/schema/index.ts
export { todos } from "./todos";
Create drizzle.config.ts in your app root for the Drizzle CLI:
// drizzle.config.ts
export default {
schema: "./infra/db/schema/index.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.TURSO_DB_URL!,
},
};
Import your schema and create the provider:
// server/providers.ts
import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";
import * as schema from "@/infra/db/schema";
const drizzleTursoProvider = createDrizzleTursoProvider({ schema });
export const providers = [drizzleTursoProvider];
Define your app's ports type:
// ports/index.ts
import type { DbPort } from "@beignet/provider-drizzle-turso";
import * as schema from "@/infra/db/schema";
export type AppPorts = {
db: DbPort<typeof schema>;
// other ports...
};
// server/index.ts
import { createNextServer } from "@beignet/next";
import { appPorts } from "@/infra/app-ports";
import { routes } from "@/server/routes";
import { providers } from "./providers";
export const server = await createNextServer({
ports: appPorts,
providers,
createContext: ({ ports }) => ({ ports }),
routes,
});
Use cases should depend on app-owned repository ports. Keep raw Drizzle access in
infrastructure, then wire the repository into ctx.ports.
// infra/todos/drizzle-todo-repository.ts
import { count, desc } from "drizzle-orm";
import { offsetPageResult } from "@beignet/core/pagination";
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
import type { TodoRepository } from "@/features/todos/ports";
import * as schema from "@/infra/db/schema";
export function createTodoRepository(
db: DrizzleTursoDatabase<typeof schema>,
): TodoRepository {
return {
async list(input) {
const rows = await db
.select()
.from(schema.todos)
.orderBy(desc(schema.todos.createdAt))
.limit(input.limit)
.offset(input.offset);
const [{ total }] = await db.select({ total: count() }).from(schema.todos);
return offsetPageResult(rows, input, total);
},
};
}
Collect repositories in one infra factory:
// infra/db/repositories.ts
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
import { createTodoRepository } from "@/infra/todos/drizzle-todo-repository";
import * as schema from "./schema";
export function createRepositories(db: DrizzleTursoDatabase<typeof schema>) {
return {
todos: createTodoRepository(db),
};
}
// features/todos/use-cases/list-todos.ts
import { normalizeOffsetPage } from "@beignet/core/pagination";
export const listTodos = useCase.query("todos.list").run(async ({ ctx }) => {
const page = normalizeOffsetPage({}, { defaultLimit: 20, maxLimit: 100 });
return ctx.ports.todos.list(page);
});
ctx.ports.db.db remains available as a provider-specific escape hatch for
infrastructure code and one-off advanced Drizzle features. Do not make it the
normal dependency for application use cases.
When @beignet/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 "@beignet/devtools";
import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";
export const providers = [
createDevtoolsProvider(),
createDrizzleTursoProvider({ schema }),
];
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 "@beignet/core/ports";
import type { DbPort } from "@beignet/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/todos/drizzle-todo-repository.ts
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
import type { TodoRepository } from "@/features/todos/ports";
import * as schema from "@/infra/db/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 "@beignet/provider-drizzle-turso";
import { createRepositories } from "@/infra/db/repositories";
createContext: async ({ ports }) => {
const repositories = createRepositories(ports.db.db);
return {
ports: {
...ports,
...repositories,
uow: createDrizzleTursoUnitOfWork({
db: ports.db.db,
eventBus: ports.eventBus,
createTransactionPorts: (tx, events) => ({
...createRepositories(tx),
events,
}),
}),
},
};
};
Inside a use case, call transaction-scoped repositories through tx and record
declared events through the use-case events helper:
const todo = await ctx.ports.uow.transaction(async (tx) => {
const created = await tx.todos.create(input);
await events.record(tx.events, 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.
Use createDrizzleTursoOutboxPort(...) when events or jobs must be recorded in
the same database transaction as the business write, then drained after commit:
import {
createOutboxEventRecorder,
drainOutbox,
} from "@beignet/core/outbox";
import {
createDrizzleTursoOutboxPort,
createDrizzleTursoOutboxSetupStatements,
createDrizzleTursoUnitOfWork,
} from "@beignet/provider-drizzle-turso";
Add Beignet's outbox table to your app-owned migration or bootstrap flow:
for (const statement of createDrizzleTursoOutboxSetupStatements()) {
await client.execute(statement);
}
Then expose transaction-scoped outbox-backed event recording:
uow: createDrizzleTursoUnitOfWork({
db: ports.db.db,
createTransactionPorts: (tx) => {
const outbox = createDrizzleTursoOutboxPort(tx);
return {
...createRepositories(tx),
events: createOutboxEventRecorder(outbox),
outbox,
};
},
});
Drain from a worker, cron route, or scheduled task:
await drainOutbox({
outbox: ports.outbox,
registry: outboxRegistry,
eventBus: ports.eventBus,
jobs: ports.jobs,
});
The default table is outbox_messages; pass tableName to both setup and port
creation if your app uses a different table name.
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) |
.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.db
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 "@beignet/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>;
};
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 repository ports and createDrizzleTursoUnitOfWork(...) for application
// workflows. Use raw db access in infra, scripts, and vendor-specific escape
// hatches.
// 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"]);
This provider follows a clean separation of concerns:
Build-time (Drizzle CLI): Configured via drizzle.config.ts
Runtime (Provider): Configured via factory function
The provider does not care where your schema file lives. You:
infra/db/schema/, db/schema.ts, etc.)import * as schema from "@/infra/db/schema"createDrizzleTursoProvider({ schema })This keeps the provider flexible and your app in control of its structure.
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 DrizzlecreateDrizzleTursoProvider<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 Beignet 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 root LibSQLDatabase<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 configurationReturns: A UnitOfWorkPort<TxPorts> that runs work inside
db.transaction(...).
createDrizzleTursoOutboxPort<TSchema>(db, options?)Factory function to create a SQL-backed OutboxPort from a root Drizzle
database or transaction client.
Parameters:
db (required): A DrizzleTursoDatabase<TSchema> root database or transactionoptions.tableName (optional): Outbox table name, defaults to "outbox_messages"options.now (optional): Test clockcreateDrizzleTursoOutboxSetupStatements(options?)Returns SQL setup statements for the app-owned outbox table and indexes. Run these through your migration/bootstrap flow or translate them into your normal Drizzle migrations.
MIT