Heximon Logo
Essentials

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.

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:

terminal
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:

terminal
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:

package.json
{
  "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:

src/database/database.config.ts
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;
The config file needs a 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/persistenceapply / status / rollback:

MigrationRunner contract
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:

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

src/database/database.module.ts — auto-migrate on boot
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:

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

status
const entries = await runner.status();
// [ { id: "20240101000000_create_tasks",  applied: true },
//   { id: "20240102000000_create_labels", applied: false } ]
Drizzle migrations are forward-only.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:

DialectConfigRunner
LibSQL / TursoDrizzleLibSQLConfigDrizzleLibSQLMigrationRunner
PostgreSQLDrizzlePgConfigDrizzlePgMigrationRunner
MySQL2DrizzleMySQL2ConfigDrizzleMySQL2MigrationRunner

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

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

run order
const manager = await app.get(TaskManager);
await manager.run(SeedTask); // the schema already exists (migrations ran on boot)
Seeding is not idempotent. Running the task twice inserts duplicate rows or throws a uniqueness-constraint error (if the schema enforces it). Callers can clear the target table first, add a guard, or treat a constraint error as a no-op. For bulk fake data, 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 useFactory provider and the unified config the CLI and runtime share.
  • Drizzle ORM — schemas, value-object columns, repositories, and relations.
  • Persistence contracts — the MigrationConfig and MigrationRunner tokens the runner implements.
  • heximon migrations — the CLI command that detects your unified config(s) and drives generate/push/studio without the --config flag.
  • Example L04 — database — a CRUD API over a single SQLite table with the unified config drizzle-kit and the runtime both read.
  • Example L06 — DDD — value-object columns, repositories, migrations applied on boot, and a SeedTask for fixture data.
Copyright © 2026