Docs/databasev1.0.0-rc.1

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 EntityManager forks 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/database Server-side ORM support, DI decorators, DbContext, migrations, and client registration helpers. Requires the optional peer @mikro-orm/core plus the driver package you use (@mikro-orm/postgresql, @mikro-orm/mysql, ...), which is loaded on demand. Import driver-specific APIs such as defineConfig, PostgreSqlDriver or Migrator from those packages directly.
  • @tagna/udiot/database/browser Browser-safe Supabase helpers.
  • @tagna/udiot/database/firebase Firebase helpers. Requires the optional peer firebase.

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, and FirebaseConfig expose @AIConfig/@AIEnv schemas 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 MikroORM instance as a singleton value
  • an EntityManager factory using ServiceScope.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' or type: 'postgresql' for SQL access
  • registerSupabaseClient() or createSupabaseClient() 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 app
  • registerFirebaseServices() registers the app and Firestore into the UDIOT container
  • createFirestoreCollectionRepository() 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 unless allowDrift: true is 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).

From the canonical UDIOT public documentation.Try it in the playground ↗