Migrations
Migrations split into two jobs Heximon keeps deliberately separate. Generating the SQL is the stock drizzle-kit generate CLI's work — it diffs your schema and writes .sql files; Heximon ships no schema-diff tooling because the upstream tool already does it well. Applying those files at runtime is an injectable MigrationRunner you wire like any other provider. Both read the same unified config, so schema paths, credentials, and the output folder live in exactly one place.
This page covers that config-and-runner mechanism directly. The heximon migrations CLI command wraps the same drizzle-kit generate/push/studio calls shown below — it auto-detects your unified config(s) and loads the right .env overlay for you, so you don't have to remember the --config flag.
Generate migrations
Your config object is a valid stock drizzle-kit Config, so the upstream CLI consumes it directly — no wrapper, no jiti patch. Every time you change a schema file, regenerate:
pnpx drizzle-kit generate --config ./src/database/database.config.ts
That writes a new migration folder into the config's out directory plus a meta/ snapshot. The other upstream commands take the same flag:
pnpx drizzle-kit push --config ./src/database/database.config.ts
pnpx drizzle-kit studio --config ./src/database/database.config.ts
Wire generation into your scripts so it's one command:
{
"scripts": {
"db:generate": "drizzle-kit generate --config ./src/database/database.config.ts"
}
}
The out directory is the same path the runner reads from — the unified config is the migration config.
DrizzleLibSQLConfig (and its pg/mysql2 twins) implements MigrationConfig by deriving migrationsFolder
from out, so the one config object drives drizzle-kit and the runtime runner. In the example app out
is resolved to an absolute path so the runner reads the right folder from any working directory:
const migrationsFolder = join(dirname(fileURLToPath(import.meta.url)), "../../database/migrations");
export const databaseConfig = new DrizzleLibSQLConfig(schema, relations, {
dialect: "sqlite",
schema: "./src/database/schema.ts",
out: migrationsFolder, // drizzle-kit writes here; the runner reads here (via migrationsFolder)
get url(): string {
return process.env["DATABASE_URL"] ?? "file::memory:?cache=shared";
},
});
export default databaseConfig;
default export so drizzle-kit --config can load it — export default databaseConfig. The same instance is imported by name by the runtime useFactory provider, so the CLI and the running app can never drift. See Database wiring.Apply migrations on boot
The runner implements the MigrationRunner contract from @heximon/persistence — apply / status / rollback:
abstract class MigrationRunner {
abstract apply(): Promise<readonly string[]>; // pending migrations, in id order
abstract status(): Promise<readonly MigrationStatusEntry[]>;
abstract rollback(): Promise<string | undefined>; // forward-only dialects reject this
}
Drive it from a Task. A task is an ordinary injectable — extends Task<...> is the discovery base, and its constructor declares the runner as a dependency, resolved by class identity. You run it through the TaskManager, which resolves the task and invokes it inside Context.run — so a migrate step is a one-line call at startup or in a deploy script:
import { Task, type TaskResult } from "@heximon/runtime";
import { MigrationRunner } from "@heximon/persistence";
export class MigrateTask extends Task<string> {
public constructor(private readonly runner: MigrationRunner) {
super();
}
public override async run(): Promise<TaskResult<string>> {
const applied = await this.runner.apply(); // idempotent — a re-run applies nothing new
return { result: `Applied ${applied.length} migration(s).` };
}
}
apply() is idempotent on purpose: it reads its bookkeeping table, applies only the pending migrations in id order, and returns their ids. A second run returns [] and leaves the schema untouched — so it's safe to run on every start, with no "first run" branch to maintain.
The module runs it for you: a Module can implement OnInit and inject the task, so migrations apply the first time the module is constructed — automatically before any request, with no manual call:
export class DatabaseModule
extends Module({ providers: [/* … see below … */] })
implements OnInit
{
public constructor(private readonly migrateTask: MigrateTask) {
super();
}
public async onInit(): Promise<void> {
await this.migrateTask.run(); // idempotent — safe on every boot
}
}
onInit auto-migrate races across multiple instances. A rolling deploy or a serverless cold-start
fan-out can boot several instances at once, each calling apply() against the same bookkeeping table —
concurrent migrate runs can conflict or double-apply. In production, apply migrations once, before
traffic shifts, with a dedicated deploy step instead: heximon deploy --migrate (see
Migrations from the CLI) or a CI job that calls the runner directly. Keep onInit
auto-migrate for local development, where there's only ever one instance.Bind the runner and its config
One config value, one DI token. The unified databaseConfig is a plain imported value; because DrizzleLibSQLConfig implements MigrationConfig, it's bound under the abstract, non-generic MigrationConfig token the runner reads. The database factory does not inject it — it closes over the same imported value. There is no DrizzleLibSQLConfig DI token: the config is a static value, not a dependency to resolve, and a generic class makes a misleading token (resolution is by class identity; type arguments are erased).
DrizzleLibSQLMigrationRunner's constructor takes the concrete AppDatabase and the abstract MigrationConfig — both plain DI tokens the compiler resolves by class identity — so it's a bare provider: list the class directly, no useFactory wrapper needed. Injecting the abstract MigrationRunner anywhere in the module (here, MigrateTask's constructor) then resolves straight through to this concrete runner:
import { Context, Module, type OnInit } from "@heximon/runtime";
import { MigrationConfig, MigrationRunner } from "@heximon/persistence";
import { DrizzleLibSQLMigrationRunner } from "@heximon/drizzle/libsql";
import { AppDatabase } from "./app-database";
import { databaseConfig } from "./database.config";
import { MigrateTask } from "./migrate.task";
export class DatabaseModule
extends Module({
providers: [
// The unified config value, bound to the abstract migration-config token the runner reads. The
// database factory closes over the same imported value — no DrizzleLibSQLConfig DI token.
{ provide: MigrationConfig, useValue: databaseConfig },
{
provide: AppDatabase,
// `AppDatabase` is an empty subclass; it inherits the `(config, context)` constructor. The factory
// closes over the imported `databaseConfig` (not a DI token) — the argument a bare class provider
// couldn't supply; the inherited `onShutdown` is detected on the result either way.
useFactory: (context: Context): AppDatabase => new AppDatabase(databaseConfig, context),
},
// A bare listing: both constructor parameters (AppDatabase, MigrationConfig) are DI tokens, so no
// useFactory wrapper is needed.
DrizzleLibSQLMigrationRunner,
MigrateTask,
],
exports: [AppDatabase, MigrationRunner, MigrateTask],
})
implements OnInit
{
public constructor(private readonly migrateTask: MigrateTask) {
super();
}
public async onInit(): Promise<void> {
await this.migrateTask.run();
}
}
This works the same way for the PostgreSQL and MySQL2 runners — every dialect runner's constructor takes
concrete, DI-resolvable parameters, so a bare listing is enough. The one exception is
DrizzleDurableSQLiteMigrationRunner (the Cloudflare Durable Object dialect): its second constructor
parameter is bundled migration data, not a class, so it still needs a useFactory provider to supply that
argument.
AppDatabase is the app's named database subclass — the DI token the repository injects — typed against the schema. Its body is empty: it inherits the (config, context) constructor, so new AppDatabase(config, context) opens a libsql client from the config and builds the ORM over this app's schema + relations. See Database wiring.
Check status
status() reports every known migration as applied or pending — handy in CI or a health check, where you want to fail loudly if a deploy left migrations unapplied:
const entries = await runner.status();
// [ { id: "20240101000000_create_tasks", applied: true },
// { id: "20240102000000_create_labels", applied: false } ]
drizzle-kit generate writes no down SQL, so rollback() rejects rather than guessing at a reverse — calling it throws a forward-only error. To undo a change, generate a new forward migration that reverses it; that keeps your history linear and replayable instead of leaving the schema in a state no migration describes.Switch dialects
The shape is identical across dialects — swap the config class, the runner, and the generate command; only the imports change:
| Dialect | Config | Runner |
|---|---|---|
| LibSQL / Turso | DrizzleLibSQLConfig | DrizzleLibSQLMigrationRunner |
| PostgreSQL | DrizzlePgConfig | DrizzlePgMigrationRunner |
| MySQL2 | DrizzleMySQL2Config | DrizzleMySQL2MigrationRunner |
Migration files layout
The out / migrationsFolder follows drizzle-kit v1's own layout — one folder per migration, named YYYYMMDDHHmmss_<name>, plus a meta/ snapshot directory:
migrations/
├── 20240101000000_create_tasks/
│ └── migration.sql
├── 20240102000000_create_labels/
│ └── migration.sql
└── meta/
├── _journal.json
└── 0000_snapshot.json
The timestamp prefix (YYYYMMDDHHmmss) is generated by drizzle-kit generate and is required — the runner
parses the first 14 characters as a timestamp to determine apply order. Commit every file. The meta/
snapshots are what drizzle-kit diffs your current schema against to produce the next migration; delete them
and the next generate re-emits the whole schema from scratch.
Seeding fixture data
Seeding is a plain Task that injects a repository and calls the aggregate factory directly — no
abstract Seeder base, no third-party seed library:
import { Task, type TaskResult, uuid } from "@heximon/runtime";
import { User, type UserId } from "../users/user.entity";
import { UserRepository } from "../users/user.repository";
export class SeedTask extends Task<string> {
public constructor(private readonly users: UserRepository) {
super();
}
public override async run(): Promise<TaskResult<string>> {
const fixtures = [
{ name: "Alice Example", email: "alice@example.com" },
{ name: "Bob Example", email: "bob@example.com" },
];
for (const fixture of fixtures) {
const user = User.create(uuid.v7<UserId>(), fixture);
await this.users.save(user);
}
return { result: `Seeded ${fixtures.length} fixture(s).` };
}
}
Wire SeedTask as a plain provider in the module that owns the repository it injects (here UserModule),
and run it via the TaskManager. Migrations have already applied at boot (DatabaseModule.onInit), so the
schema is ready — seeding is the only explicit step:
const manager = await app.get(TaskManager);
await manager.run(SeedTask); // the schema already exists (migrations ran on boot)
drizzle-seed (an optional upstream tool) is an
alternative — the SeedTask pattern is for small, deterministic, domain-rule-respecting fixture sets.See also
- Database wiring — the
useFactoryprovider and the unified config the CLI and runtime share. - Drizzle ORM — schemas, value-object columns, repositories, and relations.
- Persistence contracts — the
MigrationConfigandMigrationRunnertokens the runner implements. heximon migrations— the CLI command that detects your unified config(s) and drivesgenerate/push/studiowithout the--configflag.- Example L04 — database — a CRUD API over a single SQLite table with the unified config
drizzle-kitand the runtime both read. - Example L06 — DDD — value-object columns, repositories, migrations applied on boot, and a
SeedTaskfor fixture data.
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.
Authentication
Verify callers with JWT, hash passwords through the PasswordHashingAlgorithm port, read the request-scoped AuthContext, and list JWTAuthMiddleware directly in your app.