Heximon Logo
Essentials

Drizzle ORM

Map domain entities to Drizzle tables — value-object columns, DDD repositories, optimistic concurrency, relations, nested transactions, and seven dialects (libsql, better-sqlite3, Cloudflare D1, Durable Object SQLite, Postgres, MySQL, Cloudflare Hyperdrive MySQL).

Storing plain rows with a hand-written repository? Start at Database — this page is the deep end. Persist a modeled domain to a real database without writing a row mapper. Declare which columns a User maps to, extend a repository base, and you get save / getById / getAll / delete — value objects packed into columns and back, optimistic concurrency guarding every update.

It runs on Drizzle ORM, one repository contract across seven dialects — four SQLite drivers (libsql, better-sqlite3, Cloudflare D1, and Durable Object SQLite) plus node-postgres/PostgreSQL, mysql2/MySQL, and mysql2 over Cloudflare Hyperdrive (a real-atomicity MySQL write path on Cloudflare Workers).

src/users/user.repository.ts
import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { User } from "./user.entity";

export class UserRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, User> {
  public constructor(database: DrizzleLibSQLDatabase) {
    super(database, "Users", User); // the database is the only injected dependency
  }
}

A Database is an ordinary useFactory provider and a repository is found by the base it extends — no compiler plugin, no decorator.

Install the dialect

Add the package, Drizzle, and the driver for your dialect. The drivers are optional peers, so you pull in only the one you need.

pnpm add @heximon/drizzle drizzle-orm
pnpm add @libsql/client            # libsql / SQLite (Node, Turso)
pnpm add better-sqlite3            # synchronous native Node SQLite
pnpm add @cloudflare/workers-types # Cloudflare D1 + Durable Object SQLite (types only)
pnpm add pg                        # PostgreSQL
pnpm add mysql2                    # MySQL (Node) + Cloudflare Hyperdrive
pnpm add -D drizzle-kit            # typed config + migration generation

Choose a SQLite driver

Four drivers share the same sqlite-core schema DSL, repository base, and codecs — they differ only in how the Database connects and whether it has real transactions:

SubpathDatabase classConnectionTransactions
@heximon/drizzle/libsqlDrizzleLibSQLDatabaseurl (file: / :memory: / Turso)real SAVEPOINT
@heximon/drizzle/better-sqlite3DrizzleBetterSQLite3Databasefilename (path / :memory:), synchronousreal SAVEPOINT
@heximon/drizzle/d1DrizzleD1DatabaseCloudflare D1 bindingnon-atomic
@heximon/drizzle/durable-sqliteDrizzleDurableSQLiteDatabaseDurable Object ctx.storagenon-atomic

libsql and better-sqlite3 are URL/file connections — wire them exactly like the rest of this guide, just with their own config (the better-sqlite3 connection field is filename):

src/database/database.config.ts
import { DrizzleBetterSQLite3Config } from "@heximon/drizzle/better-sqlite3/config";
import { relations, schema } from "./schema";

export const databaseConfig = new DrizzleBetterSQLite3Config(schema, relations, {
  dialect: "sqlite",
  schema: "./src/database/schema.ts",
  out: "./migrations",
  filename: ":memory:",
});
Local SQLite handles background pollers out of the box. A single-file SQLite database has one writer, so two top-level transactions opened at once on the same connection would collide. The engine knows a local libsql / better-sqlite3 database is single-writer (database.isSingleWriter()) and serializes its top-level transactions automatically — so the durable tiers' background poll loops (the @heximon/saga timers, the @heximon/queue consumer, the transactional outbox relay) coexist with foreground request writes with no app config and no "stop the poller in tests" workaround. better-sqlite3 also opens the file in WAL mode by default (readers run concurrently with the writer), and both drivers accept a busyTimeoutMs for cross-process lock contention. For multi-process write concurrency, reach for Turso (remote libsql), Postgres, or MySQL — there the database server arbitrates and the in-process serialization is a no-op.

The two Cloudflare drivers connect by a platform binding, not a URL. D1 carries the binding name in its config; the database resolves it lazily per request via Platform.binding, so a singleton built at boot still sees the per-request env:

src/database/database.config.ts
import { DrizzleD1Config } from "@heximon/drizzle/d1/config";
import { relations, schema } from "./schema";

export const databaseConfig = new DrizzleD1Config(schema, relations, {
  dialect: "sqlite",
  schema: "./src/database/schema.ts",
  out: "./migrations",
  binding: "DB", // the D1 binding name, resolved at runtime via Platform.binding("DB")
});

Durable Object SQLite is built from the DO's own ctx.storage, injected as DurableContext. The cast to the Cloudflare DurableObjectStorage lives in a small helper, not inline in the useFactory — the compiler emits a factory body verbatim into the generated JS wiring, so the body must hold no TypeScript-only syntax (a type assertion would become invalid JavaScript there):

src/database/database.module.ts
import { type Context, Module } from "@heximon/runtime";
import type { DurableContext } from "@heximon/durable";
import type { DurableObjectStorage } from "@cloudflare/workers-types";
import { AppDatabase } from "./app-database";
import { relations, schema } from "./database.config";

// Defined outside the Module() config, so it is compiled as ordinary source (not inlined verbatim):
function resolveStorage(durable: DurableContext): DurableObjectStorage {
  return durable.storage as unknown as DurableObjectStorage;
}

export class DatabaseModule extends Module({
  providers: [
    {
      provide: AppDatabase,
      useFactory: (durable: DurableContext, context: Context) =>
        new AppDatabase(resolveStorage(durable), context, schema, relations),
    },
  ],
  exports: [AppDatabase],
}) {}
D1 and Durable Object SQLite have no interactive transaction. The D1 driver does not support .transaction(), and the DO storage API forbids BEGIN/SAVEPOINT (its only transactionSync is synchronous and cannot wrap async work). So on these two drivers runInTransaction is best-effort and non-atomic — every statement runs immediately on the bare connection, commit/rollback are no-ops, and a mid-flight failure leaves already-issued writes in place (the in-memory onRollback hooks still fire). Reach for libsql or better-sqlite3 when you need real atomicity in SQLite.

The flagship Cloudflare app runs its whole domain on the D1 dialect and hosts its real-time layer on a Durable Object; the durable-sqlite dialect itself is exercised by @heximon/drizzle's own tests (durable-sqlite-database.test.ts) rather than a standalone example.

The libsql driver is the ladder's L04 — Database; the flagship monolith runs its whole domain on the better-sqlite3 dialect.

Real MySQL on Cloudflare — Hyperdrive

@heximon/drizzle/hyperdrive runs mysql2 over Cloudflare Hyperdrive on Cloudflare Workers — a real-atomicity MySQL write path on the edge, unlike D1 and Durable Object SQLite, which are non-atomic.

DrizzleHyperdriveDatabase is a thin subclass of the Node DrizzleMySQL2Database: it inherits the transaction/savepoint engine, the pool-route override, and connection retry unchanged, so real interactive BEGIN / COMMIT / ROLLBACK and SAVEPOINT nesting work.

Three deltas make it edge-safe: a small mysql2 pool is opened per request (stored on the request Context and torn down at request end via waitUntil, so one request can run queries in parallel); every statement uses the text protocol (Hyperdrive rejects binary prepared statements); and disableEval: true (workerd blocks mysql2's eval-based row parser).

src/database/database.config.ts
import { Platform } from "@heximon/runtime";
import { DrizzleHyperdriveConfig } from "@heximon/drizzle/hyperdrive/config";
import { relations, schema } from "./schema";

export const databaseConfig = new DrizzleHyperdriveConfig(schema, relations, {
  dialect: "mysql",
  schema: "./src/database/schema.ts",
  out: "./migrations",
  binding: "HYPERDRIVE", // the CF Hyperdrive binding, resolved at runtime via Platform.binding("HYPERDRIVE")
  localConnectionString: Platform.get("DATABASE_URL"), // dev / CI / migrations — used in preference to the binding
  connectionLimit: 5, // per-request pool size (default 5), bounding per-request query parallelism
});

binding names the Cloudflare Hyperdrive binding, resolved lazily via Platform.binding("HYPERDRIVE") at request time. localConnectionString is the direct database URL used in preference to the binding when set — on Node (CI, migrations, an in-process test) the dialect connects through it directly, since Node has no Hyperdrive binding to fall back to.

vp dev runs a CloudflareWorkersStrategy app in real workerd, not Node, so this Platform.get("DATABASE_URL") read evaluates to undefined there and the dialect falls back to the binding.

Dev reaches the database instead through heximon.config.ts's cloudflare.hyperdrive[].localConnectionString (a separate field, on the Cloudflare config, not the dialect config), which makes vp dev provision the HYPERDRIVE binding against that database directly.

DrizzleHyperdriveMigrationRunner is Node/CI-only and runs migrations direct off the dialect's localConnectionString (Hyperdrive is a pooler, so DDL goes direct); it never runs at edge boot, where Workers have no filesystem.

The schema, value objects, repository, and codecs are the MySQL ones (DrizzleMySQLEntity.define, @heximon/drizzle/mysql-core) — Hyperdrive carries only the connection difference. A repository extends the re-typed DrizzleHyperdriveEntityRepository:

src/tasks/task.repository.ts
import { DrizzleHyperdriveDatabase, DrizzleHyperdriveEntityRepository } from "@heximon/drizzle/hyperdrive";
import { Task } from "./task.entity";

export class TaskRepository extends DrizzleHyperdriveEntityRepository<DrizzleHyperdriveDatabase, Task> {
  public constructor(database: DrizzleHyperdriveDatabase) {
    super(database, "Tasks", Task);
  }
}

heximon.config.ts declares the binding and its Hyperdrive configuration id so CloudflarePlugin emits the hyperdrive stanza into wrangler.json; the Worker also needs compatibility_flags: ["nodejs_compat"]. The runnable example is the flagship cloudflare-mysql app, which runs its whole domain on the Hyperdrive/MySQL dialect.

Start from a domain entity

A schema needs an entity first. A User extends AggregateRoot, which gives it framework-owned identity and metadata — id, createdAt, updatedAt, the version counter — plus the dirty tracking the repository reads at save.

src/users/user.entity.ts
import type { Branded } from "@heximon/primitives";
import { AggregateRoot } from "@heximon/domain";
import { Email } from "./email.value";

export type UserId = Branded<string, "UserId">;

interface UserProps extends Record<string, unknown> {
  name: string;
  email: Email; // a value object at the domain edge, not a raw string
}

export class User extends AggregateRoot<UserProps, UserId> {
  public static create(id: UserId, props: { name: string; email: string }): User {
    return new User(id, { name: props.name, email: Email.create(props.email) });
  }

  public get name(): string {
    return this.props.name;
  }

  public get email(): Email {
    return this.props.email;
  }

  public rename(name: string): void {
    this.props.name = name; // mutating props is what flips the entity dirty
  }
}

Mutating this.props flips the entity dirty; the next save diffs live props against the load-time snapshot, marks a real change for UPDATE, and you never call markModified(). The Domain-Driven Design page covers Entity, AggregateRoot, and ValueObject in full.

Define the table

Drizzle<Dialect>Entity.define(EntityClass, "tableName", columns) bridges a domain class to a real Drizzle table, with no codegen and no hand-written mapper. Pick the static for your dialect:

import { dateObject, DrizzleSQLiteEntity, valueObject } from "@heximon/drizzle/sqlite-core";
import { integer, text } from "drizzle-orm/sqlite-core";
import { Email } from "./email.value";
import { User, type UserId } from "./user.entity";

export const Users = DrizzleSQLiteEntity.define(User, "users", {
  id: text("id").notNull().primaryKey().$type<UserId>(),
  createdAt: dateObject("created_at").notNull(),
  updatedAt: dateObject("updated_at").notNull(),
  version: integer("version").notNull().default(1),
  name: text("name").notNull(),
  email: valueObject(Email, { address: text("email_address").notNull().unique() }),
});

Three columns carry weight. define requires version at the type level — it's the optimistic-concurrency counter, and without it concurrency control would be silently off.

.$type<UserId>() types a row's id as the branded UserId, not a bare string. And dateObject is a Heximon codec (one per dialect) that round-trips a JS Date to its native column type; the plain helpers (text, integer, varchar) come straight from drizzle-orm/<dialect>-core.

Pack a value object into columns

A value object isn't a scalar, so the schema has to say how it lands in the table. Given an Email:

src/users/email.value.ts
import { ValueObject } from "@heximon/domain";

interface EmailValue extends Record<string, unknown> {
  readonly address: string;
}

export class Email extends ValueObject<EmailValue> {
  public static create(address: string): Email {
    return new Email({ address: address.trim().toLowerCase() });
  }

  public get address(): string {
    return this.value.address;
  }
}

The choice between two strategies comes down to whether you ever query on the value's fields.

Flattened — valueObject(). Each field spreads across its own (prefixed) column, so you can filter, index, and constrain on it — reach for this when the inner fields matter to queries.

flattened
import { valueObject } from "@heximon/drizzle/sqlite-core";
import { text } from "drizzle-orm/sqlite-core";
import { Address } from "./address.value";

const addressColumns = valueObject(Address, {
  street: text("street").notNull(),
  city: text("city").notNull(),
  zip: text("zip").notNull(),
});

Serialized — serializedValueObject(). The whole value object goes into one column — simpler, but you can't filter on individual fields. Pass customTypes codecs to round-trip non-primitive fields like a Date.

serialized
import { serializedValueObject } from "@heximon/drizzle/sqlite-core";
import { text } from "drizzle-orm/sqlite-core";
import { Metadata } from "./metadata.value";

const metadataColumn = serializedValueObject(Metadata, text("metadata"));

Define a repository

A repository extends Drizzle<Dialect>EntityRepository<TDatabase, TEntity> (a subclass of the DDD Repository contract). The base ships the whole CRUD surface — getById, getAll(query?), getMany(ids), count(query?), save, delete, transaction — so a concrete repository is often just a constructor:

import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { User } from "./user.entity";

export class UserRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, User> {
  public constructor(database: DrizzleLibSQLDatabase) {
    super(database, "Users", User);
  }
}

The database is the only DI-resolved constructor parameter — the compiler reads its type as the dependency, since class identity is the only token. The schema key and entity constructor are plain super(...) arguments.

"Users" is the schema key, not the SQL table name. The base resolves the Drizzle table by this key, and that key is the export identifier of export const Users = … — not the "users" you passed to define. Get it wrong and the repository fails to construct with Table "Wrong" not found in the database schema. Use the export name; the SQL table name lives only inside define.

Register the repository in the feature module's providers:

src/users/user.module.ts
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { UserController } from "./user.controller";
import { UserRepository } from "./user.repository";

export class UserModule extends Module({
  imports: [DatabaseModule], // brings the exported Database token into scope
  providers: [UserRepository],
  http: { controllers: [UserController] },
  exports: [UserRepository],
}) {}

Save and load from a controller

A controller injects the repository and works in domain terms — create the aggregate, save it, read it back with getById. The mapper handles row translation in both directions.

src/users/user.controller.ts
import { uuid } from "@heximon/runtime";
import type { Controller, Get, Post, TypedResponse } from "@heximon/http";
import { createUserSchema, type UserResponse } from "./user.schema";
import { User, type UserId } from "./user.entity";
import { UserRepository } from "./user.repository";

export class UserController implements Controller<"/users"> {
  public constructor(private readonly users: UserRepository) {}

  public async list(_action: Get<"/">): Promise<UserResponse[]> {
    const users = await this.users.getAll();
    return users.map((user) => UserController.toResponse(user));
  }

  public async create(action: Post<"/", { body: typeof createUserSchema }>): Promise<TypedResponse> {
    const body = await action.request.readValidatedBody(); // invalid → 400 problem+json
    const user = User.create(uuid.v7<UserId>(), body); // mint the id up front
    await this.users.save(user);
    return action.respond(201, UserController.toResponse(user));
  }

  // `version` is protected on the entity — read it through the public getter when projecting a response.
  private static toResponse(user: User): UserResponse {
    return { id: user.id, name: user.name, email: user.email.address, version: user.getVersion() };
  }
}

The body is validated against any Standard Schema before your handler runs, and Email re-validates the address when the aggregate is built — a malformed address can't reach the row.

How save decides INSERT vs. UPDATE

You never tell save to insert or update — it reads a DirtyState the entity carries:

StateHow it got thereWhat save does
DetachedJust created (User.create(...))INSERT
PersistentLoaded from the database, untouchedNothing — no change to write
TransientA loaded entity whose props were mutatedVersion-guarded partial UPDATE

save snapshot-diffs at save time, and loading a row sets it Persistent (restoring its version) — all automatically. Multi-row inserts are chunked to stay inside each dialect's bound-parameter limit.

Guard concurrent writes

The version column stops two requests overwriting each other. The base emits its UPDATE guarded by WHERE version = ?: if another worker bumped the row's version since you loaded it, zero rows match, save throws ConcurrencyError, and the in-memory entity's version and dirty-state roll back.

import { ConcurrencyError } from "@heximon/runtime/errors";

You rarely catch it yourself. Pair it with CQRS automatic retry and the losing command re-runs against fresh state, so the handler never sees the conflict.

Compose relations across features

Each feature owns its own tables; at the app level you stitch them together so cross-feature relations resolve against the merged schema. First aggregate the tables into one namespace — the map both the runtime ORM and drizzle-kit read:

src/database/schema.ts
export * from "../users/user.schema";
export * from "../orders/order.schema";

Then declare the relations. Even with no foreign keys the config is required, because getById / getAll only resolve tables that appear in it. For a flat schema, defineRelations(schema) registers every table with no relations:

src/database/relations.ts
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";

export const relations = defineRelations(schema);

As foreign keys appear, write per-feature relation builders and merge them. Because schema already holds every table, an Order → User relation resolves without the domain modules importing each other:

src/database/relations.ts
import { DrizzleRelations } from "@heximon/drizzle/core";
import * as schema from "./schema";

const usersRelations = (r) => ({
  Users: { orders: r.many.Orders({ from: r.Users.id, to: r.Orders.userId }) },
});

const ordersRelations = (r) => ({
  Orders: { user: r.one.Users({ from: r.Orders.userId, to: r.Users.id }) },
});

export const relations = DrizzleRelations.mergeRelations(schema, [usersRelations, ordersRelations]);

To hydrate related rows on every query instead of writing manual with(...) clauses, pass an eager graph as the final super(...) argument:

eager loading
export class OrderRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, Order> {
  public constructor(database: DrizzleLibSQLDatabase) {
    super(database, "Orders", Order, {
      user: true, // include the order's user
      items: { with: { product: true } }, // include each item and its product
    });
  }
}

Run multiple writes in one transaction

When two writes must both succeed or both roll back, wrap them in runInTransaction(handler):

transaction
await userRepository.runInTransaction(async () => {
  await userRepository.save(user); // both writes join the same active transaction
  await orderRepository.save(order);
});

Propagation is ambientsave and delete find the active transaction on the request Context, so neither takes a handle. Transactions nest: a transactional service that calls another opens a savepoint in the outer transaction instead of deadlocking, committing or rolling back to that point on its own.

Repository calls outside an HTTP request need a Context. The framework opens one per request, but a test, script, or CLI must do it explicitly — without a context the call throws No active context — open one with Context.run(). Wrap the call:
await context.run(async () => {
  await userRepository.save(user);
});

Transaction lifecycle hooks

The Drizzle database exposes three hooks you register on the currently active transaction. All three are scoped per transaction handle and are no-ops when no transaction is active.

// onBeforeCommit — run INSIDE the transaction just before the outermost commit.
// A throw aborts the commit and rolls the whole transaction back.
database.onBeforeCommit(async () => {
  await outboxStore.flush(); // append outbox rows atomically with the aggregate write
});

// onCommit — run once the outermost transaction has durably committed.
// The commit is already permanent; a throwing hook is logged and absorbed (never re-thrown).
database.onCommit(async () => {
  await relay.scheduleNextDrain(); // dispatch integration events after the durable write
});

// onRollback — run when the active transaction rolls back.
// Used internally by the repository to restore in-memory aggregate state.
database.onRollback(() => {
  entity.restoreFromSavepoint(); // keep in-memory entity state consistent with the rolled-back row
});

Both onBeforeCommit and onCommit hooks bubble to the outermost transaction: a hook registered inside a nested savepoint is moved to the parent when the savepoint commits, and fires only when the outermost transaction commits. A rolled-back transaction never fires its commit hooks. onRollback hooks work the inverse way — they fire for the innermost failing scope (the savepoint that rolled back), not the parent.

These hooks are what make the drizzle transactional outbox reliable: the producer registers an onBeforeCommit hook that appends the outbox rows to the same transaction the aggregate write runs in, then an onCommit hook that schedules the relay drain after the durable commit.

Deep aggregate persistence

save is deep: it persists the aggregate root and its eager-loaded children in one transaction. Orphan removal runs automatically — a child that was loaded and then removed from a collection is DELETEd when the root is saved.

For save to reach a child entity you must declare it in the repository's relatedEntities graph (the same graph that configures eager loading on reads):

src/orders/order.repository.ts
import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { Order } from "./order.entity";

export class OrderRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, Order> {
  public constructor(database: DrizzleLibSQLDatabase) {
    super(database, "Orders", Order, {
      items: true, // load and deep-save the order's items
    });
  }
}

The deep write runs in three phases per save:

  1. Snapshot orphan pre-image. The child primary keys present at the last load (the entity's snapshot) are captured before any mutation — these are the "known children" the diff runs against.
  2. Cascade save. Each child entity's dirty state is settled (commitChanges), then routed: Detached → batched INSERT, Transient (mutated since load) → version-guarded UPDATE, Persistent (unchanged) → no query. Optimistic concurrency is enforced at every level.
  3. Orphan removal. The current child keys are compared against the pre-image; any key that was loaded but is no longer present in the collection gets a cascade DELETE (deepest relation first, so foreign keys hold).

An in-memory savepoint is captured before the write and restored on rollback (via an onRollback hook), so the aggregate's dirty state, version, and snapshot all revert with the SQL rows if the transaction fails.

Orphan removal only reaches children in the eager-load graph. A child collection that was NOT declared in relatedEntities is never loaded and therefore never diffed — removing an item from the collection without first loading the aggregate leaves orphaned rows in the database.

SQL outbox store

DrizzleOutboxStore is the drizzle implementation of the DDD OutboxStore port. It stores outbox rows in a table you declare in your schema by spreading the dialect-core outboxColumns() helper:

import { outboxColumns } from "@heximon/drizzle/sqlite-core";
import { sqliteTable } from "drizzle-orm/sqlite-core";

export const outbox = sqliteTable("outbox", { ...outboxColumns() });

Then bind OutboxStore to a DrizzleOutboxStore constructed over your database and the table, with a useFactory provider — the same shape every other DI-can't-express construction takes:

src/orders/order.module.ts
import { OutboxStore } from "@heximon/integration";
import { DrizzleOutboxStore } from "@heximon/drizzle/core";
import { outbox } from "./order.schema";

// inside the module's providers:
{
  provide: OutboxStore,
  useFactory: (database: OrderSqliteDatabase): OutboxStore =>
    new DrizzleOutboxStore(database, outbox),
}

DrizzleOutboxStore implements the full OutboxStore protocol:

MethodWhat it does
append(records)INSERTs rows through executeStatement — routes into the active transaction, so rows commit atomically with the aggregate write
claimPending(limit)Opens its own fresh transaction, claims up to limit pending rows in id order with per-dialect locking (pg/mysql FOR UPDATE SKIP LOCKED; SQLite serializes via the write transaction itself), marks them claimed
markProcessed(ids)DELETEs the delivered rows
markFailed(id, attempt)Persists the bumped attempt count and clears the claim; dead-letters the row once attempt reaches the threshold (default 5)

The full reliable tier — outbox store + producer + relay — is wired as three providers in the feature module; see Flagship — transactional outbox for the complete wiring.

Wire the database once

A Database is one instance per app, bound as a useFactory provider so every repository shares it and the ambient transaction stays request-wide. The factory's parameter type is its dependency — no token:

src/database/database.module.ts
import { Context, Module } from "@heximon/runtime";
import { DrizzleLibSQLDatabase } from "@heximon/drizzle/libsql";
import { databaseConfig } from "./database.config";

export class DatabaseModule extends Module({
  providers: [
    {
      provide: DrizzleLibSQLDatabase,
      useFactory: (context: Context) => new DrizzleLibSQLDatabase(databaseConfig, context),
    },
  ],
  exports: [DrizzleLibSQLDatabase], // feature modules import this to inject the database
}) {}

The databaseConfig is authored once and consumed twice — by the runtime factory and by drizzle-kit for migration generation. The Database wiring page walks through the unified config and lifecycle.

Migrations

Heximon ships the migration runner, not a schema-diff CLI: you author .sql with stock drizzle-kit generate (it reads the same config), and the runner applies an already-generated folder at boot. Application is idempotent and forward-only, since Drizzle emits no down SQL. The Migrations page covers generating and applying them end-to-end.

Custom column codecs

Each dialect ships codecs that round-trip non-trivial values. Import them from the dialect's *-core subpath — not the root barrel, where the differently-typed dateObjects would collide:

DialectSubpathCodecs
SQLite / libsql@heximon/drizzle/sqlite-coredateObject (ISO text ⇄ Date), noCaseText
PostgreSQL@heximon/drizzle/pg-coredateObject (→ TIMESTAMPTZ(3)), uuid (→ native uuid)
MySQL@heximon/drizzle/mysql-coredateObject (→ DATETIME(3)), uuid (→ CHAR(36)), binaryUuid (→ BINARY(16))

See also

  • Database wiring — the useFactory provider, the unified config, and the per-app Database lifecycle.
  • Migrations — generate with drizzle-kit, apply on boot with the migration runner.
  • Persistence contracts — the dialect-agnostic Database / runInTransaction / MigrationRunner surface Drizzle implements.
  • Domain-Driven Design — the Entity / AggregateRoot / ValueObject bases these schemas persist.
  • Repository Pattern — the Repository contract behind the Drizzle base, and the optimistic concurrency that feeds CQRS retry.
  • Example L06 — DDD — a User aggregate over libsql with a flattened Email column, plus a test that compiles the app, drives the /users endpoints, and proves the ConcurrencyError on a stale concurrent save.
  • Example L04 — database — the minimal database wiring without the DDD layer, for a feel of the bare Database + repository setup.
  • L06 — DDD — an Order aggregate with value objects and domain events, the pattern these repositories persist.
  • Flagship — transactional outbox — the full outbox tier: DrizzleOutboxStore + OutboxIntegrationEventTransport producer + relay, wired so integration events are published reliably after commit.
Copyright © 2026