Database
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.
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:
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.
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:
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:
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:
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:
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:
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.
Next steps
- Migrations — generate SQL with
drizzle-kit generateagainst this same config, then apply it at boot with an injectableMigrationRunner. - 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 withcreateTestApp.
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.
REST Client
Call a shared Contract from the frontend by constructing it with a transport — ClientTransport.fetch, ClientTransport.internal, and ClientTransport.mock, throwing 2xx-body callers, raw() for the full status union, request/response interceptors, and vue-query bindings.
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).