ADR 0006 — Base de datos: plugin, tablas centrales y aprovisionamiento
- Estado: aceptada
- Fecha: 2026-09-25
Contexto
La Fase 2 agrega MySQL/MariaDB y PostgreSQL (una base por tenant) sin que el núcleo conozca Kysely ni ningún motor.
Decisiones
- Plugin, no dependencia del núcleo.
@tenancy-node/dbes unTenancyPlugin: aporta los repositorios SQL, el aprovisionamiento, elDatabaseBootstrappery los métodosdb(),centralDb(),sql,pool()ydatabase.*.createTenancy({ plugins: [database({ driver: mysql(), ... })] })los tipa automáticamente. - Drivers como Strategy.
@tenancy-node/db-mysqly@tenancy-node/db-postgresimplementanDatabaseDriver(crear/borrar bases y usuarios, candados, detección de duplicados).@tenancy-node/dbno importamysql2nipg(regladb-is-engine-agnostic). - Una sola fuente de migraciones para las 11 tablas
tenancy_*, escrita con el schema builder de Kysely. El prefijo se aplica conTablePrefixPlugin, así las consultas del paquete usan nombres cortos y tipados. - Columnas de base del tenant anulables.
database_server_idydatabase_namequedan enNULLhasta que el aprovisionamiento ubica al tenant (el plan v3 las teníaNOT NULL). Así un tenant puede existir antes de tener base y se evita inventar un nombre antes de validar colisiones. - Tablas de control. Migraciones del paquete:
tenancy_migrations(central). Tus migraciones centrales:tenancy_central_migrations. Migraciones de cada tenant:tenancy_migrationsen su base. ElMigratorde Kysely agrega su tabla de candado*_lock. - Aprovisionamiento idempotente con candado.
GET_LOCK/pg_try_advisory_lockpor tenant; cada paso queda entenancy_provisioning_stepsy en el log (operation: provisioning.step). Al reintentar se saltan los pasos que ya terminaron (salvo si hubocleanup), así el seed no corre dos veces. - Inserción que detecta duplicados.
TenantWriter.insertyDomainWriter.createlanzanTenantAlreadyExistsError/DomainAlreadyTakenErrora partir de la restricción única de la base: dos creaciones en paralelo nunca crean el mismo tenant. - Pools con conteo de referencias.
ConnectionPoolRegistry(LRU por servidor) nunca cierra un pool en uso; si todos están ocupados excede el límite temporalmente, lo avisa en el log y vuelve al límite al liberarse. - Secretos cifrados con AES-256-GCM (
tn1.<kid>.<iv>.<tag>.<datos>). El id de la llave permite convivir con llaves anteriores (previousKeys) ydatabase.rotateKey()vuelve a cifrar todo.
Consecuencias
- En MySQL el DDL no es transaccional: si
install()falla a mitad, hay que revisar la base antes de reintentar. - Crear el tenant y sus dominios todavía no es atómico: si falla el alta de un dominio, el tenant queda creado sin él (la outbox de la Fase 5 hace atómicos los eventos, no esta alta).
- Los tests de integración usan Testcontainers (Docker).
TENANCY_SKIP_DB_TESTS=1los salta.