Persistence & Transactions
You want a persistence boundary you can swap dialects under: write your repositories against one
abstract Database, and decide later whether it's backed by SQLite, Postgres, or MySQL.
@heximon/persistence gives you abstract classes and structural interfaces with no SQL or ORM
types in them. A dialect package binds a concrete implementation; your code never sees the difference.
This page covers both halves: the contracts every dialect implements, and the wiring that plugs a concrete dialect in. The Drizzle-specific schema and repository APIs live on the Drizzle ORM page.
Install the contracts
The contracts hold no driver types, so there's nothing extra to install — the driver itself (e.g.
@libsql/client) comes in with whichever dialect package implements these contracts.
pnpm add @heximon/persistence
npm install @heximon/persistence
yarn add @heximon/persistence
The bare specifier and its ./core subpath export the same symbols; reach for ./core only when you're
authoring a dialect, to match the import path dialect packages use.
import { Database, MigrationRunner } from "@heximon/persistence";
Bind a concrete database
A database in Heximon is an ordinary DI provider — no Drizzle compiler plugin, no runtime container.
You bind a dialect's Database class to the DI token with a useFactory, and the compiler constructs it
directly. A DatabaseModule owns that single instance and exports it so feature modules can inject it.
import { type 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],
}) {}
The factory's parameter type is its dependency declaration — there's no inject array and no token
list. The compiler reads (context: Context), resolves the built-in Context by class identity, and
threads it in.
Author the unified config
The config object is authored once and consumed by both the runtime factory and the stock
drizzle-kit CLI, so the migration tooling and the running app can never read different definitions.
import { DrizzleLibSQLConfig } from "@heximon/drizzle/libsql/config";
import { relations, schema } from "./schema";
export const databaseConfig = new DrizzleLibSQLConfig(schema, relations, {
dialect: "sqlite",
schema: "./src/database/schema.ts",
out: "./migrations",
url: ":memory:",
});
export default databaseConfig; // `drizzle-kit --config` loads the default export
The dialect, schema path, out, and derived dbCredentials let drizzle-kit --config consume the
instance directly — no wrapper CLI — while its schema, relations, and connection fields are what a
Database is built from at runtime.
For edge runtimes, where process.env isn't populated at import time, make the connection a getter so
credentials resolve the moment the config is read:
import { Platform } from "@heximon/runtime";
import { DrizzleMySQL2Config } from "@heximon/drizzle/mysql2";
import { relations } from "./relations";
import * as schema from "./schema";
export default new DrizzleMySQL2Config(schema, relations, {
dialect: "mysql",
schema: "./src/database/schema.ts",
out: "./database/migrations",
get connection() {
return {
database: Platform.get("DB_NAME"),
host: Platform.get("DB_HOST"),
user: Platform.get("DB_USER"),
password: Platform.get("DB_PASSWORD"),
};
},
});
The three dialect config classes differ only in how the connection is expressed — flat for libsql, a
nested connection (the driver's pool options) for mysql2 and pg:
| Dialect | Config class | Subpath |
|---|---|---|
| LibSQL / Turso | DrizzleLibSQLConfig | @heximon/drizzle/libsql/config |
| MySQL2 | DrizzleMySQL2Config | @heximon/drizzle/mysql2/config |
| PostgreSQL | DrizzlePgConfig | @heximon/drizzle/pg/config |
Inject the one shared instance
There is exactly one Database instance per app, by design: transaction routing lives on the
Database, not on each repository, so every repository must share one connection pool and one
transaction slot the engine can nest savepoints in across a single request. Because every feature imports
the same DatabaseModule, every repository injects that one singleton.
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],
providers: [UserRepository],
http: { controllers: [UserController] },
exports: [UserRepository],
}) {}
A repository takes the database through its constructor and runs queries through the dialect's ORM. The
parameter is annotated with the DrizzleLibSQLDatabase class name — that name is the DI token, so the
shared instance arrives by class identity:
import { DrizzleLibSQLDatabase } from "@heximon/drizzle/libsql";
import { eq } from "drizzle-orm";
import { tasks } from "../database/schema";
export class TasksRepository {
public constructor(private readonly database: DrizzleLibSQLDatabase) {}
public async findById(id: number): Promise<typeof tasks.$inferSelect | undefined> {
const rows = await this.database.getOrm().select().from(tasks).where(eq(tasks.id, id)).limit(1);
return rows[0];
}
}
export class AppDatabase extends DrizzleLibSQLDatabase<typeof schema, typeof relations> {}.
Repositories then inject AppDatabase (not the schema-erased base), so the entity repository's
TableNames<AppDatabase> narrows to the real schema keys and super(database, "Users", User) is checked
against them at compile time. See the ladder's L06 — DDD
for the named-subclass token wired end-to-end.The Database surface
Database<TTransaction> is both the dialect-agnostic connection surface and the class-identity DI
token a dialect binds its implementation to. Your repositories depend on the abstract base; a concrete
subclass is what's actually wired.
abstract class Database<TTransaction extends TransactionHandle = TransactionHandle> {
abstract executeStatement(statement: DatabaseStatement): Promise<AffectedRows>;
abstract isWriteStatement(statement: DatabaseStatement): boolean;
abstract runInTransaction<Result>(
work: (transaction: TTransaction) => Promise<Result>,
): Promise<Result>;
abstract onRollback(hook: () => void | Promise<void>): void;
abstract onCommit(hook: () => void | Promise<void>): void;
abstract onBeforeCommit(hook: () => void | Promise<void>): void;
}
executeStatementroutes a statement through the active transaction (or the bare connection) and reports rows affected.isWriteStatementclassifies a statement as a write.runInTransactionopens (or nests) a transaction and commits-or-rolls-back.onRollbackregisters a callback that runs when the active transaction rolls back (in-memory savepoint restore). A no-op when no transaction is active.onCommitregisters a callback that runs after the outermost transaction durably commits (outbox dispatch, cache invalidation). Fires only at the outermost commit boundary — a nested savepoint hands its hooks up to the parent. A throwing hook is absorbed (the commit is already permanent). A no-op when no transaction is active.onBeforeCommitregisters a callback that runs inside the transaction just before the outermost commit is issued (a last write/flush within the same atomic scope). A throw aborts the commit and rolls the whole transaction back. A no-op when no transaction is active.
It carries no SQL or Drizzle types, which keeps it swappable — a statement is opaque, so nothing from one ORM leaks into the contract:
type DatabaseStatement = unknown; // a Drizzle QueryPromise, a raw SQL string + bindings, …
type AffectedRows = number; // a conditional UPDATE … WHERE version = ? that affects
// zero rows signals a stale-version conflict
The class is abstract, so a direct new Database(...) is a build error — only a concrete dialect subclass
is ever bound to the token.
runInTransaction — transactions and savepoint nesting
Database.runInTransaction(work) is the transaction abstraction — there is no separate unit-of-work
object. A dialect implements it directly: open a transaction — or a savepoint when one is already active for
the request — run work, then commit on success or roll back on throw (re-raising the original
error).
Nesting is automatic and savepoint-style: an inner rollback unwinds only the inner savepoint and leaves the outer transaction intact.
abstract class Database<TTransaction extends TransactionHandle = TransactionHandle> {
abstract runInTransaction<Result>(
work: (transaction: TTransaction) => Promise<Result>,
): Promise<Result>;
abstract onRollback(hook: () => void | Promise<void>): void;
abstract onCommit(hook: () => void | Promise<void>): void;
abstract onBeforeCommit(hook: () => void | Promise<void>): void;
// … executeStatement / isWriteStatement
}
await context.run(async () => {
await database.runInTransaction(async (outer) => {
await database.executeStatement({ write: true, sql: "OUTER" });
// Inner scope throws → only the inner savepoint rolls back; the outer stays alive.
await expect(
database.runInTransaction(async () => {
await database.executeStatement({ write: true, sql: "INNER" });
throw new Error("inner failed");
}),
).rejects.toThrow("inner failed");
await database.executeStatement({ write: true, sql: "OUTER-AFTER" });
});
});
// committed writes === ["OUTER", "OUTER-AFTER"] (INNER dropped)
All transaction state lives in a request-scoped slot on the request Context, so concurrent requests stay
isolated. A transaction therefore only nests inside an active request: the framework opens that Context per
request, and tests open it explicitly with Context.run(...).
Optimistic concurrency
Versioned is a structural interface — not a class, so any persisted shape satisfies it — carrying a
monotonically increasing version. A dialect persists it in an integer column, reads the in-memory version
on save, issues a conditional UPDATE … WHERE version = <expected>, and treats "zero rows updated" as a
conflict. No row locks held across the request, just a version check at write time.
interface Versioned {
readonly version: number;
}
interface ConcurrencyConflictDetails {
readonly resource: string;
readonly expectedVersion: number;
readonly actualVersion?: number;
}
class ConcurrencyConflictError extends ConcurrencyError {
static override readonly errorName: string; // "ConcurrencyConflictError"
readonly details: ConcurrencyConflictDetails;
constructor(details: ConcurrencyConflictDetails);
}
ConcurrencyConflictError extends the core ConcurrencyError (from @heximon/runtime/errors), so
existing catch sites that key on ConcurrencyError — including the CQRS command retry — still match, while
the subclass carries the expected and actual versions for diagnostics. Catch the base to handle any
conflict; catch the subclass when you want the version detail.
import { ConcurrencyError } from "@heximon/runtime/errors";
import { ConcurrencyConflictError } from "@heximon/persistence";
database.save({ id: "athlete-1", version: 1 }); // stored version is now 2
try {
database.save({ id: "athlete-1", version: 1 }); // writer still holds the stale version 1
} catch (error) {
expect(error).toBeInstanceOf(ConcurrencyConflictError);
expect(error).toBeInstanceOf(ConcurrencyError); // extends the core hierarchy
expect((error as ConcurrencyConflictError).details).toEqual({
resource: "athlete-1",
expectedVersion: 1,
actualVersion: 2,
});
}
That shared hierarchy is what makes the CQRS command retry work without any coupling:
the repository throws, and the command bus — which already retries on ConcurrencyError — re-runs the
command with fresh state.
Migration contracts
The package defines the migration contracts; a dialect supplies the runner. File generation is the
stock drizzle-kit generate CLI's job — these contracts only apply / report an already-generated folder,
which keeps schema authoring in one well-known tool. Migrations are forward-only: rollback() rejects because
drizzle-kit generate writes no down SQL.
interface MigrationStatusEntry {
readonly id: string;
readonly applied: boolean;
}
abstract class MigrationConfig {
abstract readonly migrationsFolder: string; // the one required field
readonly migrationsTable: string | undefined = undefined; // dialect default when omitted
readonly migrationsSchema: string | undefined = undefined; // dialect default when omitted
}
abstract class MigrationRunner {
abstract apply(): Promise<readonly string[]>; // pending migrations, in id order; idempotent
abstract rollback(): Promise<string | undefined>; // forward-only — always rejects
abstract status(): Promise<readonly MigrationStatusEntry[]>;
}
MigrationConfig is the config a runner reads to locate and record migrations (modeled on Drizzle's
migrationsFolder / migrationsTable / migrationsSchema). Only migrationsFolder is abstract — a runner
must know where the migrations live; the bookkeeping table/schema overrides default to undefined.
You rarely write a standalone config: the Drizzle Drizzle<Dialect>Config implements MigrationConfig
(deriving migrationsFolder from drizzle-kit's out), so a single unified config
binds to both the Database factory and the MigrationRunner. See Migrations for the
one-config wiring.
MigrationRunner is the apply/status surface the app and tests drive; the concrete runner is the dialect's
job. @heximon/drizzle supplies DrizzleLibSQLMigrationRunner, DrizzleMySQL2MigrationRunner, and
DrizzlePgMigrationRunner. apply() is idempotent — a second call applies nothing and returns []. See
Migrations for the generate-then-apply workflow.
How a dialect implements the contract
If you're plugging in a new dialect, a concrete Database implements the connection surface and drives its
own begin·commit·rollback mechanics through the TransactionContext fork — reading the active handle back to
decide whether to open a top-level transaction or nest a savepoint:
import {
type AffectedRows,
Database,
type DatabaseStatement,
} from "@heximon/persistence/core";
export class InMemoryDatabase extends Database<InMemoryTransaction> {
private readonly transactionContext: TransactionContext;
// …
public override async runInTransaction<Result>(
work: (transaction: InMemoryTransaction) => Promise<Result>,
): Promise<Result> {
const parent = this.transactionContext.getActive<InMemoryTransaction>();
const handle = parent === undefined ? await this.begin() : await this.beginSavepoint(parent);
return this.transactionContext.runInTransaction(handle, async () => {
try {
const result = await work(handle);
await this.commit(handle);
return result;
} catch (error) {
await this.rollback(handle);
throw error;
}
});
}
}
url needs cache=shared, or your tables vanish between connections. A bare
:memory: libsql URL creates a separate private database per connection — a table created on one
connection is invisible to another, and the moment a raw client and the ORM (or two providers) both run
queries you get SqliteError: no such table. Point every connection at the same in-memory database with
url: "file::memory:?cache=shared", or use a real file path or remote libsql:// URL in production.See also
- Drizzle ORM — the concrete dialect: schemas, value-object columns, entity repositories, relations, and transactions across SQLite, Postgres, and MySQL.
- Migrations — generate with drizzle-kit and apply on boot with the migration runner.
- Domain-Driven Design — entities and aggregates whose repositories sit on this
Databaseboundary. - CQRS — the command retry that consumes
ConcurrencyErrorfrom an optimistic write. - the ladder's L04 — Database
— a repository over a single shared
DrizzleLibSQLDatabase, wired by auseFactoryand re-exported by class identity. - the ladder's L06 — DDD
— entity repositories, value-object columns, and the version-guarded
UPDATEthat proves theConcurrencyErrorpath.
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).
Migrations
Generate Drizzle migrations with stock drizzle-kit, then apply them with the injectable MigrationRunner, MigrationConfig, and a Task — one unified config drives both the CLI and the runtime.