4.1 KiB
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)
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 directorygetPendingMigrations(pool)— Compares files vsschema_migrationstable, returns pendingmigrate(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, callsmigrate()on each. Logs progress and errors per tenant. Does not stop on individual tenant failure.
Integration Points
-
TenantConnectionManager.provisionDatabase()— ReplacecreateTables()+createIndexes()calls withmigrate(pool). This applies all migrations (starting from 001) to new tenants. -
TenantConnectionManager.getPool()— After creating or retrieving a pool, callmigrate(pool)if not already verified this session. UsesmigratedPools: Set<string>to cache which tenants have been checked. Cache clears on process restart. -
New Turborepo script
db:migrate-tenants— RunsmigrateAll()for eager deployment. Added toapps/api/package.jsonand rootturbo.json. -
createTables()andcreateIndexes()— Removed fromTenantConnectionManager. Their content moves to001_initial_schema.sql.
Lazy Migration Cache
// In TenantConnectionManager
private migratedPools: Set<string> = new Set();
getPool()checksmigratedPools.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 frommigratedPools
Deploy Flow
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
- Create
NNN_description.sqlinapps/api/src/migrations/tenant/ - Write idempotent SQL
- 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 |