Files
HoruxDespachos/docs/superpowers/specs/2026-04-13-tenant-migrations-design.md
2026-04-27 22:09:36 -06:00

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 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<string> 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

// In TenantConnectionManager
private migratedPools: Set<string> = 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

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