# Tenant Schema Migrations System **Date:** 2026-04-13 **Status:** Approved ## Problem Horux360 uses a database-per-tenant architecture. When schema changes are made to `createTables()` or `createIndexes()` in `TenantConnectionManager`, only newly provisioned tenants get the updated schema. Existing tenants' databases drift from the expected structure, requiring manual ALTER scripts. ## Solution A numbered SQL migration system for tenant databases, with both eager (deploy-time) and lazy (on-connect) execution. ## Architecture ### Migration Files ``` apps/api/src/migrations/tenant/ 001_initial_schema.sql # Current createTables() + createIndexes() 002_example_future.sql # Template for future changes ``` - Naming: `NNN_description.sql` (zero-padded 3 digits) - Each file must be idempotent (use `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, etc.) - Files are read from disk at runtime, sorted by version number ### Schema Migrations Table (per tenant DB) ```sql CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL, applied_at TIMESTAMP DEFAULT NOW() ); ``` Created automatically before running any migration. ### TenantMigrationRunner New file: `apps/api/src/config/tenant-migrations.ts` **Exported functions:** - `getMigrationFiles()` — Reads and sorts SQL files from migrations directory - `getPendingMigrations(pool)` — Compares files vs `schema_migrations` table, returns pending - `migrate(pool, databaseName?)` — Applies pending migrations in order, each in its own transaction. Returns count of applied migrations. - `migrateAll()` — Queries all active tenants from central DB, calls `migrate()` on each. Logs progress and errors per tenant. Does not stop on individual tenant failure. ### Integration Points 1. **`TenantConnectionManager.provisionDatabase()`** — Replace `createTables()` + `createIndexes()` calls with `migrate(pool)`. This applies all migrations (starting from 001) to new tenants. 2. **`TenantConnectionManager.getPool()`** — After creating or retrieving a pool, call `migrate(pool)` if not already verified this session. Uses `migratedPools: Set` to cache which tenants have been checked. Cache clears on process restart. 3. **New Turborepo script `db:migrate-tenants`** — Runs `migrateAll()` for eager deployment. Added to `apps/api/package.json` and root `turbo.json`. 4. **`createTables()` and `createIndexes()`** — Removed from `TenantConnectionManager`. Their content moves to `001_initial_schema.sql`. ### Lazy Migration Cache ```typescript // In TenantConnectionManager private migratedPools: Set = new Set(); ``` - `getPool()` checks `migratedPools.has(tenantId)` before running migrations - If not in set → run `migrate(pool)` → add to set - Set clears on PM2 restart (new process = fresh set) - `invalidatePool()` also removes from `migratedPools` ### Deploy Flow ```bash git pull pnpm install pnpm build pnpm db:migrate-tenants # Eager: apply to all tenants pm2 restart all # Lazy: safety net on connect ``` ### Adding Future Schema Changes 1. Create `NNN_description.sql` in `apps/api/src/migrations/tenant/` 2. Write idempotent SQL 3. Deploy — eager applies to all, lazy catches stragglers ## Scope Exclusions - No rollback support - No data migrations (DDL only; data scripts remain separate) - No parallel execution (sequential per tenant) - No distributed locking (single PM2 fork instance) - No changes to Prisma/central DB migrations ## Files Changed | File | Change | |------|--------| | `apps/api/src/config/tenant-migrations.ts` | NEW — TenantMigrationRunner | | `apps/api/src/migrations/tenant/001_initial_schema.sql` | NEW — current createTables + createIndexes | | `apps/api/src/config/database.ts` | MODIFY — remove createTables/createIndexes, add lazy migration in getPool, call migrate in provisionDatabase | | `apps/api/src/scripts/migrate-tenants.ts` | NEW — eager migration CLI script | | `apps/api/package.json` | MODIFY — add db:migrate-tenants script | | `turbo.json` | MODIFY — add db:migrate-tenants task |