Database Module Overview
@tagna/udiot/database adds an optional database layer to UDIOT without changing the framework core. The module is built around MikroORM for SQL workloads and keeps data access aligned with UDIOT's existing dependency injection and request-scope model.
Why This Shape
- Optional by design: database support lives under its own entry point, so middleware, state, and browser-only apps do not pay for it.
- DI-first: ORM instances, request-scoped entity managers, repositories, Supabase clients, and Firebase services can all be registered into the existing
Container. - AI-first: agents can inspect supported backends, configuration schemas, stable tokens, and registered database descriptors directly from runtime exports.
- Performance-aware: the default registration path uses scoped
EntityManagerforks instead of a shared mutable unit of work. - Full-stack friendly: SQL runs through MikroORM on the server, Supabase helpers are available through
@tagna/udiot/database/browser, and Firebase helpers through@tagna/udiot/database/firebase.
Entry Points
@tagna/udiot/databaseServer-side ORM support, DI decorators,DbContext, migrations, and client registration helpers. Requires the optional peer@mikro-orm/coreplus the driver package you use (@mikro-orm/postgresql,@mikro-orm/mysql, ...), which is loaded on demand. Import driver-specific APIs such asdefineConfig,PostgreSqlDriverorMigratorfrom those packages directly.@tagna/udiot/database/browserBrowser-safe Supabase helpers.@tagna/udiot/database/firebaseFirebase helpers. Requires the optional peerfirebase.
AI-First Runtime Helpers
Agents can consume the database module without reverse-engineering project code:
describeSupportedDatabases()returns structured backend capability descriptors.describeDatabaseConventions()returns the preferred build pattern for agents.listRegisteredDatabases(container)returns structured runtime descriptors for every registered integration.getRegisteredDatabase(connectionName, container)returns one specific descriptor.DatabaseConfig,SupabaseConfig, andFirebaseConfigexpose@AIConfig/@AIEnvschemas for environment generation and validation.
Supported Databases
The built-in driver mapping supports:
- PostgreSQL
- Supabase (as PostgreSQL)
- CockroachDB (via PostgreSQL driver)
- MySQL
- MariaDB (via MySQL driver)
- SQLite
- libSQL / Turso
- SQL Server
- MongoDB
Some drivers are optional and require their MikroORM package to be installed before use.
Quick Start
import 'reflect-metadata';
import { Container, Injectable, ServiceScope } from '@tagna/udiot';
import {
EntitySchema,
InjectRepository,
registerMikroOrm,
type EntityRepository,
} from '@tagna/udiot/database';
class User {
id!: number;
email!: string;
}
const UserSchema = new EntitySchema<User>({
class: User,
properties: {
id: { type: 'number', primary: true },
email: { type: 'string' },
},
});
@Injectable({ scope: ServiceScope.SCOPED })
class UserService {
constructor(
@InjectRepository(User) private readonly users: EntityRepository<User>,
) {}
list(): Promise<User[]> {
return this.users.findAll();
}
}
const container = new Container();
await registerMikroOrm(container, {
ormOptions: {
type: 'postgresql',
clientUrl: process.env.DATABASE_URL,
entities: [UserSchema],
},
});
Request-Scoped Repositories
registerMikroOrm() registers:
- the
MikroORMinstance as a singleton value - an
EntityManagerfactory usingServiceScope.SCOPED - one repository token per entity using
ServiceScope.SCOPED
This means child containers created by createRequestScopeMiddleware() automatically receive a separate EntityManager fork for each request.
Agent Discovery Example
import {
describeSupportedDatabases,
listRegisteredDatabases,
describeDatabaseConventions,
} from '@tagna/udiot/database';
const supported = describeSupportedDatabases();
const guidance = describeDatabaseConventions();
const runtime = listRegisteredDatabases(container);
DbContext Pattern
If you prefer an Entity Framework-style DbContext, extend the provided base class:
import { Injectable, ServiceScope } from '@tagna/udiot';
import {
DbContext,
InjectEntityManager,
type EntityManager,
} from '@tagna/udiot/database';
@Injectable({ scope: ServiceScope.SCOPED })
class AppDbContext extends DbContext {
constructor(@InjectEntityManager() em: EntityManager) {
super(em);
}
users() {
return this.set(User);
}
}
Supabase
For Supabase, use either:
- MikroORM with
type: 'supabase'ortype: 'postgresql'for SQL access registerSupabaseClient()orcreateSupabaseClient()for direct platform features such as auth, storage, or realtime
Firebase
Firebase support is intentionally separate from the SQL ORM layer and lives in its own entry point, so applications that do not use Firebase never install it.
import { registerFirebaseServices } from '@tagna/udiot/database/firebase';
createFirebaseApp()initializes or reuses a Firebase appregisterFirebaseServices()registers the app and Firestore into the UDIOT containercreateFirestoreCollectionRepository()provides a light repository-style wrapper over Firestore Lite
Migrations
Two paths, depending on how you access the database:
MikroORM Migrator
Import Migrator from @mikro-orm/migrations (an optional peer) to drive
entity-aware migrations through the ORM's own tooling. Pair with
createMikroOrmMigrationCheck(orm) from @tagna/udiot/web to fail server
startup when migrations are pending.
Plain pg SQL-file runner
For apps that talk directly to a pg.Pool (bypassing MikroORM), the
framework ships an opinionated SQL-file migration runner with lock-safe
concurrency and checksum drift detection.
import { Pool } from 'pg';
import { createPostgresMigrationRunner } from '@tagna/udiot/database';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const runner = createPostgresMigrationRunner({
pool,
directory: 'migrations', // *.sql files applied in lexical order
// tableName?: '__udiot_migrations' (default)
// allowDrift?: false (refuses to apply when an existing file's
// checksum has changed; override only when
// you know what you're doing)
});
const result = await runner.up();
console.log(`applied: ${result.applied.length}, skipped: ${result.skipped.length}`);
Guarantees:
- Files are applied in lexical order (use
0001_*.sql,0002_*.sql, …). - A Postgres advisory lock is held for the duration so concurrent boots from rolling deploys don't race.
- Each file runs in its own transaction; failures roll back atomically.
- SHA-256 checksums of applied files are recorded in
__udiot_migrations; the runner refuses to proceed if a recorded checksum differs from the current file content unlessallowDrift: trueis set.
Migration readiness check
Plug straight into WebServerOptions.readiness.checks so /health/ready
flips to 503 while migrations are pending:
import { createPostgresMigrationCheck } from '@tagna/udiot/database';
import { createWebServer } from '@tagna/udiot/web';
await createWebServer({
rootDir: process.cwd(),
readiness: {
checks: {
migrations: createPostgresMigrationCheck({ pool, directory: 'migrations' }),
},
},
});
Transactions
withTransaction(pool, fn) is a small helper that opens a connection, runs
your callback inside BEGIN/COMMIT, and rolls back atomically on any
thrown error. Avoids the "did I remember to release on error?" footgun.
import { withTransaction } from '@tagna/udiot/database';
await withTransaction(pool, async (client) => {
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId]);
});
pool and the inner client are typed via PgPoolLike /
PgPoolClientLike — structural interfaces, so any pool implementation that
matches the shape works (the real pg.Pool, mocks in tests, custom wrappers).