Heximon Logo
Essentials

Database

Wire a real SQLite database into a CRUD API with no install and no compiler plugin — the unified DrizzleLibSQLConfig, a named database DI token, a repository over getOrm(), and boot-time table creation.

You can go from nothing to a real database-backed CRUD API without standing up a database server: an in-memory SQLite database, a typed table, a repository, and a controller — the same DI you already know, wired to Drizzle ORM instead of hand-written SQL.

terminal
curl -X POST http://localhost:3004/tasks -H 'content-type: application/json' -d '{"title":"Write docs"}'
curl http://localhost:3004/tasks

That's the whole shape this page builds: a tasks table, a repository that runs typed queries against it, and a controller that turns HTTP requests into repository calls. No Drizzle compiler plugin exists — persistence is a runtime library you wire through the same providers/useFactory mechanism as anything else.

Declare the schema

The table is stock Drizzle — nothing Heximon-specific. If you already know Drizzle's SQLite column builders, this file needs no explanation:

src/database/schema.ts
import { defineRelations } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const tasks = sqliteTable("tasks", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  done: integer("done").notNull().default(0),
});

export const schema = { tasks };
export const relations = defineRelations(schema);

export type TaskRow = typeof tasks.$inferSelect;

done is stored as SQLite's 0/1 integer (SQLite has no native boolean) — the repository below maps it to a real boolean at the edge. schema is the name-to-table map both the runtime ORM and the CLI below read; relations is called even with a single table so the relational-query surface stays well-formed as you add more.

Author the unified config

One config object is read by two consumers: the Heximon runtime, which builds the database from it, and the stock drizzle-kit CLI, which can generate and push migrations against the same instance — no second config file, no wrapper.

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 type DatabaseSchema = typeof schema;
export type DatabaseRelations = typeof relations;

url: ":memory:" opens an in-process SQLite database with no filesystem footprint — no server to run, nothing to configure beyond pnpm install. It resets on every restart, which is exactly right for this page and for tests.

Name the database as a DI token

Dependency injection resolves by class identity, and generic type arguments are erased once resolution happens — so the generic DrizzleLibSQLDatabase<Schema, Relations> can't be a token: every parameterization would collapse onto the same class. Fixing the schema and relations types in a named, otherwise-empty subclass gives you one concrete class every consumer can inject:

src/database/app-database.ts
import { DrizzleLibSQLDatabase } from "@heximon/drizzle/libsql";
import type { DatabaseRelations, DatabaseSchema } from "./database.config";

export class AppDatabase extends DrizzleLibSQLDatabase<DatabaseSchema, DatabaseRelations> {}

The body is empty on purpose — it inherits the (config, context) constructor unchanged. AppDatabase is now both the DI token and a fully-typed ORM handle for this app's exact tables.

Provide it with a useFactory — no compiler plugin

There is no Drizzle compiler plugin, because there doesn't need to be one: the database is an ordinary provider. The factory's one parameter is the framework Context, resolved by its type exactly like a constructor dependency — no token, no decorator. The config is a closed-over value (never a token), and the factory is keyed by the concrete AppDatabase class, so every consumer resolves to the one shared instance:

src/database/database.module.ts
import { type Context, Module } from "@heximon/runtime";
import { AppDatabase } from "./app-database";
import { databaseConfig } from "./database.config";

export class DatabaseModule extends Module({
  providers: [
    {
      provide: AppDatabase,
      useFactory: (context: Context) => new AppDatabase(databaseConfig, context),
    },
  ],
  exports: [AppDatabase],
}) {}

Any module that imports DatabaseModule can now inject AppDatabase — persistence is wired through the same DI graph as everything else, no special-casing at compile time.

Write the repository

A repository is a plain class that injects AppDatabase by constructor parameter, exactly like injecting any other provider. It runs typed queries through getOrm() — inserts, selects, updates, deletes — all checked against your schema at compile time:

src/tasks/tasks.repository.ts
import { type OnInit } from "@heximon/runtime";
import { eq, sql } from "drizzle-orm";
import { AppDatabase } from "../database/app-database";
import { tasks, type TaskRow } from "../database/schema";

export interface Task {
  readonly id: number;
  readonly title: string;
  readonly done: boolean;
}

export class TasksRepository implements OnInit {
  private readonly database: AppDatabase;

  public constructor(database: AppDatabase) {
    this.database = database;
  }

  public async onInit(): Promise<void> {
    await this.database.getOrm().run(sql`
      CREATE TABLE IF NOT EXISTS tasks (
        id    INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        done  INTEGER NOT NULL DEFAULT 0
      )
    `);
  }

  public async list(): Promise<Task[]> {
    const rows = await this.database.getOrm().select().from(tasks).orderBy(tasks.id);
    return rows.map((row) => TasksRepository.toTask(row));
  }

  public async findById(id: number): Promise<Task | undefined> {
    const rows = await this.database.getOrm().select().from(tasks).where(eq(tasks.id, id)).limit(1);
    const row = rows[0];
    return row === undefined ? undefined : TasksRepository.toTask(row);
  }

  public async create(title: string): Promise<Task> {
    const [row] = await this.database.getOrm().insert(tasks).values({ title, done: 0 }).returning();
    if (row === undefined) {
      throw new Error("Insert returned no row.");
    }
    return TasksRepository.toTask(row);
  }

  private static toTask(row: TaskRow): Task {
    return { id: row.id, title: row.title, done: row.done === 1 };
  }
}

onInit runs once, in dependency order, the first time something resolves TasksRepository — in practice, the first request that hits /tasks — and it completes before any repository method runs. That makes it the right place to create a table for an in-memory database that starts empty on every boot. A file-backed or hosted database wouldn't do this; see Toward production below.

Wire the controller

The controller side is nothing new: a plain class injecting the repository, with routes declared by each handler's action parameter type — see Controllers for the full mechanics.

export class TasksController implements Controller<"/tasks"> {
  public constructor(private readonly tasks: TasksRepository) {}

  public async list(_action: Get<"/">): Promise<Task[]> {
    return this.tasks.list();
  }
}

The feature module imports DatabaseModule so its provider can inject AppDatabase, then declares the repository and controller like any other feature:

src/tasks/tasks.module.ts
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { TasksController } from "./tasks.controller";
import { TasksRepository } from "./tasks.repository";

export class TasksModule extends Module({
  imports: [DatabaseModule],
  providers: [TasksRepository],
  http: { controllers: [TasksController] },
  exports: [TasksRepository],
}) {}

The only compiler plugin this app registers is the HTTP one, for the controller — the database needs none:

heximon.config.ts
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";

export default defineHeximonConfig({ plugins: [new HttpPlugin()] });

Toward production

Swap url: ":memory:" in database.config.ts for url: "file:./app.db" to persist between restarts, or a libsql://… Turso URL (with an authToken) for a hosted database.

Once the database survives a restart, the boot-time CREATE TABLE in onInit stops being safe — a second boot would run against a database that already has the table, and a real schema change needs a real migration. Generate one with the same unified config and apply it with an injectable runner instead: see Migrations.

This repository is hand-rolled CRUD, and that's the right tool while your persistence logic stays straightforward. When your domain earns richer modeling — richer column mapping, optimistic concurrency on every update, automatic row-to-object mapping — see Drizzle ORM for the repository base that does it for you.

Next steps

  • Migrations — generate SQL with drizzle-kit generate against this same config, then apply it at boot with an injectable MigrationRunner.
  • Drizzle ORM — value-object columns, automatic row mapping, and optimistic concurrency once a repository outgrows hand-written queries.
  • Testing — drive this exact config → factory → repository → controller chain through real fetch(Request) calls with createTestApp.

See also

  • Example L04 — Database — the full CRUD API this page is built from, plus a test that compiles the app, boots the generated server, and drives every route end to end.
Copyright © 2026