Heximon Logo
Essentials

Persistence & Transactions

A dialect-agnostic persistence boundary — the abstract Database surface, runInTransaction transactions, optimistic concurrency with ConcurrencyConflictError, MigrationRunner contracts, and how a concrete dialect plugs in via a useFactory provider.

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

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.

src/database/database.module.ts
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.

src/database/database.config.ts
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:

src/database/database.config.ts
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:

DialectConfig classSubpath
LibSQL / TursoDrizzleLibSQLConfig@heximon/drizzle/libsql/config
MySQL2DrizzleMySQL2Config@heximon/drizzle/mysql2/config
PostgreSQLDrizzlePgConfig@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.

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],
  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:

src/users/user.repository.ts
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];
  }
}
For a handle narrowed to your app's schema, give the database a named subclass and use it as the DI token: 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;
}
  • executeStatement routes a statement through the active transaction (or the bare connection) and reports rows affected.
  • isWriteStatement classifies a statement as a write.
  • runInTransaction opens (or nests) a transaction and commits-or-rolls-back.
  • onRollback registers a callback that runs when the active transaction rolls back (in-memory savepoint restore). A no-op when no transaction is active.
  • onCommit registers 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.
  • onBeforeCommit registers 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;
      }
    });
  }
}
An in-memory libsql 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 Database boundary.
  • CQRS — the command retry that consumes ConcurrencyError from an optimistic write.
  • the ladder's L04 — Database — a repository over a single shared DrizzleLibSQLDatabase, wired by a useFactory and re-exported by class identity.
  • the ladder's L06 — DDD — entity repositories, value-object columns, and the version-guarded UPDATE that proves the ConcurrencyError path.
Copyright © 2026