Database and transactions

Beignet keeps database access behind app-owned ports. It gives you repository and Unit of Work conventions, but it does not hide Drizzle, Prisma, Kysely, or SQL behind a generic ORM abstraction.

The recommended framework path today is Drizzle through @beignet/provider-db-drizzle. The default starter uses the /sqlite subpath, a libSQL-backed provider that works with local SQLite files in development and Turso's hosted libSQL in production. Pass --db postgres or --db mysql to bun create beignet to scaffold the same structure against the other backends — see Other databases.

Read this page when a feature needs durable persistence, transactions, repository tests, seeds, or local database lifecycle commands.

Keep schema, app repositories, and feature ports in predictable places:

infra/
  db/
    schema/
      index.ts
      posts.ts
      comments.ts
    repositories.ts
    test-database.ts
  posts/
    drizzle-post-repository.ts
features/
  posts/
    ports.ts
    seeds/
      demo-posts.ts
    tests/
      factories/
        post.ts
        index.ts
      persistence.test.ts
drizzle/
  *.sql
drizzle.config.ts

Feature code owns the repository interface. Infra owns the Drizzle implementation. Server wiring adapts the raw Drizzle port into app-facing repository ports.

Repository ports

Use cases should depend on repository ports, not a raw database client:

// features/posts/ports.ts
import type {
  CursorPage,
  CursorPageInfo,
  PageResult,
  SortOption,
} from "@beignet/core/pagination";

export interface PostRepository {
  findMany(input: {
    page: CursorPage;
    cursor?: { sortValue: string; id: string } | null;
    filters?: { status?: PostStatus };
    sort?: SortOption<"createdAt" | "title">;
  }): Promise<PageResult<Post, CursorPageInfo>>;
  findBySlug(slug: string): Promise<Post | null>;
  create(input: CreatePostInput): Promise<Post>;
}

Infrastructure adapts a concrete database to that port:

// infra/posts/drizzle-post-repository.ts
import { cursorPageResult } from "@beignet/core/pagination";
import { desc, eq } from "drizzle-orm";
import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";
import type { PostRepository } from "@/features/posts/ports";
import * as schema from "@/infra/db/schema";
import { encodePostCursor } from "./post-cursor";

export function createDrizzlePostRepository(
  db: DrizzleSqliteDatabase<typeof schema>,
): PostRepository {
  return {
    async findMany(input) {
      const rows = await db
        .select()
        .from(schema.posts)
        .orderBy(desc(schema.posts.createdAt))
        .limit(input.page.limit + 1);
      const pageRows = rows.slice(0, input.page.limit);
      const nextCursor =
        rows.length > input.page.limit && pageRows.length > 0
          ? encodePostCursor(pageRows[pageRows.length - 1])
          : null;

      return cursorPageResult(pageRows.map(toPost), input.page, nextCursor);
    },

    async findBySlug(slug) {
      const [row] = await db
        .select()
        .from(schema.posts)
        .where(eq(schema.posts.slug, slug))
        .limit(1);

      return row ? toPost(row) : null;
    },
  };
}

The key detail is the DrizzleSqliteDatabase parameter. It accepts both the root Drizzle database and a transaction client, so the same repository factory works for normal reads and transaction-scoped writes.

Cursor encoding is app plumbing: generated resources include small app-owned base64url cursor encode/decode helpers next to the repository, and hand-written repositories should keep equivalent helpers.

Factories and seeds

Factories and seeds should stay feature-owned and persist through repository ports. This keeps test/demo data on the same app boundary as use cases:

// features/posts/tests/factories/post.ts
import { createFactory } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";

export const postFactory = createFactory("posts.post", {
  defaults: ({ sequence }) => ({
    name: `Post ${sequence}`,
  }),
  persist: (ctx: AppContext, input) => ctx.ports.posts.create(input),
});
// features/posts/seeds/demo-posts.ts
import { defineSeed } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { postFactory } from "@/features/posts/tests/factories";

export const demoPostsSeed = defineSeed("posts.demo-posts", {
  run: async (ctx: AppContext) => {
    await postFactory.createList(ctx, 3);
  },
});

Generate the starter files with:

beignet make factory posts/post
beignet make seed posts/demo-posts

The app-owned infra/db/seed.ts entrypoint decides which feature seeds run for local/demo environments. New apps do not scaffold seeds; add infra/db/seed.ts and a db:seed package script alongside your first generated seeds. Beignet never auto-runs seeds during migrations or application startup.

Repository factory

Collect app repositories in one infra factory:

// infra/db/repositories.ts
import type { DrizzleSqliteDatabase } from "@beignet/provider-db-drizzle/sqlite";
import { createDrizzlePostRepository } from "@/infra/posts/drizzle-post-repository";
import type { AppRepositoryPorts } from "@/ports";
import * as schema from "./schema";

export function createRepositories(
  db: DrizzleSqliteDatabase<typeof schema>,
): AppRepositoryPorts {
  return {
    posts: createDrizzlePostRepository(db),
  };
}

This keeps server/index.ts from importing every repository adapter directly and gives Unit of Work one place to create transaction-scoped ports.

Server wiring

The Drizzle provider installs the provider-owned db port. An app-owned database provider in infra/db/provider.ts turns it into repository ports, idempotency, and Unit of Work. Use the curried createProvider<Requires, Context, ServiceInput>() form so the required db port, the app context, and the provided ports stay typed without casts:

// infra/db/provider.ts
import { createProvider } from "@beignet/core/providers";
import {
  createDrizzleSqliteIdempotencyPort,
  createDrizzleSqliteUnitOfWork,
  type DbPort,
} from "@beignet/provider-db-drizzle/sqlite";
import type { AppContext } from "@/app-context";
import type { AppPorts } from "@/ports";
import type { AppServiceContextInput } from "@/server";
import { createRepositories } from "./repositories";
import type * as schema from "./schema";

export const appDatabaseProvider = createProvider<
  { db: DbPort<typeof schema> },
  AppContext,
  AppServiceContextInput
>()({
  name: "app-database",
  async setup({ ports }) {
    const repositories = createRepositories(ports.db.db);
    const idempotency = createDrizzleSqliteIdempotencyPort(ports.db.db);

    const providedPorts: Pick<AppPorts, "posts" | "idempotency" | "uow"> = {
      ...repositories,
      idempotency,
      uow: createDrizzleSqliteUnitOfWork({
        db: ports.db.db,
        createTransactionPorts: (tx) => ({
          ...createRepositories(tx),
          idempotency: createDrizzleSqliteIdempotencyPort(tx),
        }),
      }),
    };

    return { ports: providedPorts };
  },
});

Register it in server/providers.ts after createDrizzleSqliteProvider, which installs the db port it requires. The repository keys stay deferred in infra/app-ports.ts, and the server fails boot if a deferred port is still unbound after providers have started.

ctx.ports.db.db is an infrastructure escape hatch. Keep it out of use cases. Use cases should call ctx.ports.posts or ctx.ports.uow.transaction(...).

List queries

Use @beignet/core/pagination for list boundaries. Contracts still own their query schema, while use cases normalize the validated input before calling a repository:

import { normalizeCursorPage } from "@beignet/core/pagination";

const page = normalizeCursorPage(input, {
  defaultLimit: 20,
  maxLimit: 100,
});

return ctx.ports.posts.findMany({
  page,
  cursor: input.cursor ? decodePostCursor(input.cursor) : null,
  filters: { status: input.status },
  sort: { field: "createdAt", direction: "desc" },
});

List responses should use items for the records and page for pagination metadata. Generated resources use cursor metadata with nextCursor and hasMore, filter names with case-insensitive contains matching, and sort only by allowlisted fields. Keep filters and sort values as app-owned plain objects so Beignet does not become a query builder.

Optimistic concurrency

Generated CRUD resources include this convention by default: schemas expose a numeric version, update bodies send it back, repositories compare and increment it in one statement, and stale updates map to the generated conflict catalog error.

Repository writes include the expected version in the WHERE clause and increment it in the same statement:

const [row] = await db
  .update(schema.posts)
  .set({
    title: input.title,
    version: input.expectedVersion + 1,
    updatedAt: new Date().toISOString(),
  })
  .where(
    and(
      eq(schema.posts.slug, input.slug),
      eq(schema.posts.tenantId, input.tenantId),
      eq(schema.posts.version, input.expectedVersion),
      isNull(schema.posts.deletedAt),
    ),
  )
  .returning();

If no row is updated, check whether the active row still exists. Return a not-found result when it does not, and a conflict result when the row exists with a different version. Use cases can map that conflict to an app error such as POST_VERSION_CONFLICT. Action routes that have no request body can carry the expected version in a header instead.

Soft delete and archive

For records that matter later, prefer lifecycle columns over hard deletes:

export const posts = sqliteTable("posts", {
  id: text("id").primaryKey(),
  tenantId: text("tenant_id").notNull(),
  version: integer("version").notNull().default(1),
  deletedAt: text("deleted_at"),
  archivedAt: text("archived_at"),
  createdAt: text("created_at").notNull(),
  updatedAt: text("updated_at").notNull(),
});

Normal findMany and findBy... repository methods should filter out deletedAt and archivedAt records by default; expose explicit recovery or admin methods when an app needs the rest. Use soft delete to retain records for recovery, audit, or compliance; use archive to move a record out of the active workflow; reserve hard delete for records your app may physically erase.

Record history

Audit logs answer "who did what"; record history answers "what changed on this record." When a feature needs history, keep it behind a feature-owned repository port (for example PostHistoryRepository.record(...) with before and after snapshots, actor fields, and occurredAt), and write history rows inside the same Unit of Work transaction as the business change so history commits and rolls back with the data. For large or sensitive records, store redacted snapshots or field-level patches instead of full JSON. The important convention is that history is append-only and transaction-scoped.

Transactions

Use ctx.ports.uow.transaction(...) when a workflow needs multiple operations to commit or rollback together:

const createPostUseCase = useCase
  .command("posts.create")
  .input(CreatePostInputSchema)
  .output(PostSchema)
  .run(async ({ ctx, input }) =>
    ctx.ports.uow.transaction((tx) => tx.posts.create(input)),
  );

When a use case records domain events, expose the transaction-local recorder in your transaction ports and publish events after commit:

type AppTransactionPorts = AppRepositoryPorts & {
  events: DomainEventRecorderPort;
};

uow: createDrizzleSqliteUnitOfWork({
  db: ports.db.db,
  eventBus: ports.eventBus,
  createTransactionPorts: (tx, events) => ({
    ...createRepositories(tx),
    events,
  }),
});

Then record events inside the transaction:

const post = await ctx.ports.uow.transaction(async (tx) => {
  const created = await tx.posts.create(input);
  await events.record(tx.events, postCreated, { postId: created.id });
  return created;
});

The mechanics: events recorded inside the transaction are discarded on rollback; on commit, the helper validates, parses, and flushes them to eventBus. If flushing fails after commit, transaction(...) rejects but the database transaction is already committed. See Workflows and state machines for the after-commit concept and Outbox when events or jobs need durable delivery guarantees.

Put every durable write that must commit with the business change behind a transaction-scoped port created from the Unit of Work transaction client: repository writes, history rows, audit entries, outbox records, and durable idempotency reservations. The Drizzle/libSQL convention rebuilds those ports from tx inside createTransactionPorts; root ports stay useful for reads and background work but do not join the current transaction.

Other databases

@beignet/provider-db-drizzle ships one subpath per backend — /sqlite (libSQL), /postgres (node-postgres), and /mysql (mysql2, MySQL 8.0+) — and all three expose the same provider, Unit of Work, outbox, and idempotency surface. Everything on this page carries over: contracts, use cases, policies, and routes keep depending on ports; only the infra adapter and provider wiring change.

Pick the backend when you create the app:

bun create beignet my-app --db postgres

--db accepts sqlite (the default), postgres, and mysql; in interactive mode a database prompt appears alongside the other setup prompts. The starter scaffolds the chosen backend end to end: provider wiring, an idiomatic Drizzle schema, the vendored initial migration (including the provider's idempotency setup statements), POSTGRES_DB_URL or MYSQL_DB_URL env examples, and a matching infra/db/test-database.ts. Later make resource and make feature runs detect the app's backend from infra/db/repositories.ts and generate dialect-correct schema and repository code.

Postgres apps need a running Postgres 14+ server for development and builds; MySQL apps need MySQL 8.0+. beignet db generate and beignet db migrate work unchanged for every dialect — each starter sets the matching drizzle-kit dialect — but Postgres and MySQL need the server running first. See Quickstart for docker one-liners.

Timestamps are ISO-8601 text in every dialect

Scaffolded app tables are idiomatic per dialect — native booleans, varchar ids on MySQL — with one deliberate exception: timestamp columns are ISO-8601 UTC strings in text columns in all three dialects. Cursors, optimistic concurrency checks, and contract responses compare and serialize timestamps as strings, so keeping the storage format identical keeps pagination and conflict semantics identical across backends. A later release may move the Postgres starter to native timestamptz.

Testing per backend

Each starter writes a dialect-matched infra/db/test-database.ts. SQLite tests use an in-memory libSQL database, and Postgres tests run against in-process PGlite — both are zero infrastructure. MySQL has no in-process engine, so tests that go through createTestDatabase() need a real server: the generated helper reads MYSQL_TEST_URL and throws with a docker one-liner when it is unset. The MySQL starter's own generated tests use in-memory fakes and pass without a server.

Switching an existing app

Apps created before --db existed, or apps changing backends after creation, switch manually. For Postgres, install the driver (bun add pg), set POSTGRES_DB_URL, change the drizzle.config.ts dialect to "postgresql", and swap the subpath imports:

// server/providers.ts
import { createDrizzlePostgresProvider } from "@beignet/provider-db-drizzle/postgres";
import * as schema from "@/infra/db/schema";

export const providers = [createDrizzlePostgresProvider({ schema })];
// infra/db/provider.ts — inside the app database provider's setup({ ports })
import {
  createDrizzlePostgresIdempotencyPort,
  createDrizzlePostgresUnitOfWork,
} from "@beignet/provider-db-drizzle/postgres";

uow: createDrizzlePostgresUnitOfWork({
  db: ports.db.db,
  createTransactionPorts: (tx) => ({
    ...createRepositories(tx),
    idempotency: createDrizzlePostgresIdempotencyPort(tx),
  }),
}),

Repository factories take DrizzlePostgresDatabase<typeof schema> instead of DrizzleSqliteDatabase, and the outbox and idempotency tables come from createDrizzlePostgresOutboxSetupStatements() and createDrizzlePostgresIdempotencySetupStatements() run through your migration flow. MySQL mirrors this with @beignet/provider-db-drizzle/mysql, MYSQL_DB_URL, and DrizzleMysql naming.

The @beignet/provider-db-drizzle README is the deep per-backend reference, including pool options, the PlanetScale mode for MySQL, and the design notes shared across backends.

Migrations and local setup

Keep Drizzle CLI config at the app root:

// drizzle.config.ts
export default {
  schema: "./infra/db/schema/index.ts",
  out: "./drizzle",
  dialect: "sqlite",
  dbCredentials: {
    url: process.env.SQLITE_DB_URL ?? "file:local.db",
    authToken: process.env.SQLITE_DB_AUTH_TOKEN,
  },
};

New apps ship with the initial migration vendored into the scaffold's drizzle/ folder, so beignet db migrate is the first database command you run. There is no bootstrap DDL at application boot: the schema comes entirely from migrations, the vendored one plus the ones you generate.

Use Beignet database lifecycle commands from the app root when the schema or local data changes:

beignet db generate
beignet db migrate
beignet db seed
beignet db reset

beignet db generate and beignet db migrate delegate to the app's Drizzle Kit scripts. beignet db seed and beignet db reset delegate to app-owned entrypoints such as infra/db/seed.ts and infra/db/reset.ts. The CLI checks prerequisites before it runs the package script, and doctor reports drift in the same places, plus missing schema index exports and reset files that no longer mention BEIGNET_ALLOW_DATABASE_RESET.

For local SQLite development, keep SQLITE_DB_URL unset or set it to a file: URL. For hosted libSQL deployments such as Turso, set SQLITE_DB_URL and SQLITE_DB_AUTH_TOKEN in the deployment environment and run migrations as an explicit deployment step. Treat seeds as local/demo data unless the app owns a separate production seed entrypoint. The generated reset script refuses to run against non-local database URLs unless BEIGNET_ALLOW_DATABASE_RESET=true is set.

Testing

Repository tests should run against an isolated local database. The starter writes infra/db/test-database.ts with this shape: an in-memory database that applies the app's migrations, the same DDL path production uses. Keep the helper in infra and the behavior test with the feature:

// infra/db/test-database.ts
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { createRepositories } from "./repositories";
import * as schema from "./schema";

export async function createTestDatabase() {
  const client = createClient({ url: "file::memory:" });
  const db = drizzle(client, { schema });
  await migrate(db, { migrationsFolder: "drizzle" });

  return {
    repositories: createRepositories(db),
    reset: async () => {
      await client.execute("DELETE FROM posts");
    },
    close: async () => {
      client.close();
    },
  };
}
// features/posts/tests/persistence.test.ts
import { createDatabaseTestHarness } from "@beignet/core/testing";
import { demoPostsSeed } from "@/features/posts/seeds";
import { postFactory } from "@/features/posts/tests/factories";

const databaseHarness = createDatabaseTestHarness({
  create: createTestDatabase,
  ctx: (database) => ({ repositories: database.repositories }),
  reset: (database) => database.reset(),
  close: (database) => database.close(),
  factories: [postFactory],
  seeds: [demoPostsSeed],
});

afterEach(async () => {
  await databaseHarness.cleanup();
});

const { ctx } = await databaseHarness.setup({ seed: true });
const post = await postFactory.create(ctx, {
  title: "Database conventions",
  content: "Use repository ports from use cases.",
});

expect(
  await ctx.repositories.posts.findBySlug({
    slug: post.slug,
    tenantId: post.tenantId,
  }),
).toMatchObject({ id: post.id });

Use createNoopUnitOfWork(...) for pure use-case tests that do not need a real database transaction. Use a real local database test when the behavior belongs to SQL, indexes, joins, constraints, or repository mapping.

Factories and seeds live with the feature because they describe app data, not database tables. Their persist functions should call repository ports so the same setup works against memory ports, isolated local databases, or transaction scoped test contexts.

When to use what

Use ctx.ports.posts directly for simple reads and operations that do not need a transaction. Use ctx.ports.uow.transaction(...) for writes that coordinate multiple repositories, emit domain events, enqueue jobs, send notifications, or need a clear commit boundary.