Skip to main content

Database Migrations

Intellicon CRM uses a custom migration system designed for schema-per-tenant architecture. All migrations are defined in a single file and applied to every tenant schema.

Migration System Overview

Unlike traditional ORM migrations that generate files per change, Intellicon uses a single source of truth: an array of named migrations in one TypeScript file.

Source:  apps/api/src/scripts/run-tenant-migrations.ts
Runner: npx ts-node apps/api/src/scripts/run-tenant-migrations.ts
Tracker: "${schema}".schema_migrations table

How It Works

  1. The script loads all tenant schemas from public.tenants
  2. For each schema, it reads schema_migrations to find already-applied migrations
  3. It runs only pending migrations in order
  4. Each successful migration is recorded in schema_migrations
┌──────────────────────┐
│ run-tenant-migrations│
│ .ts │
│ │
│ migrations = [ │
│ { name, sql }, │
│ { name, sql }, │
│ ... │
│ ] │
└──────┬───────────────┘


┌──────────────────────┐ ┌──────────────────┐
│ For each tenant: │───▶│ tenant_acme │
│ - Check applied │ │ .schema_migrations│
│ - Run pending │ └──────────────────┘
│ - Record success │ ┌──────────────────┐
│ │───▶│ tenant_corp │
└──────────────────────┘ │ .schema_migrations│
└──────────────────┘

Migration Naming Convention

Migrations use a sequential numeric prefix followed by a descriptive name:

001_initial_schema
002_add_custom_fields
003_add_pipelines
...
025_stage_ownership
026_your_new_migration
warning
  • Always use the next sequential number
  • Never reuse a migration number
  • Never edit an existing migration — append a new one

Writing Migrations

Basic Structure

{
name: '026_add_proposals_table',
sql: `
CREATE TABLE IF NOT EXISTS "${schema}".proposals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
status VARCHAR(50) DEFAULT 'draft',
amount DECIMAL(15,2),
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_proposals_status
ON "${schema}".proposals(status);
CREATE INDEX IF NOT EXISTS idx_proposals_created_by
ON "${schema}".proposals(created_by);
`
}
Critical: Use ${schema} not ${schemaName}

The migration script's loop variable is schema, not schemaName. Using the wrong variable will cause a runtime error.

// CORRECT
`CREATE TABLE IF NOT EXISTS "${schema}".my_table ...`

// WRONG — will fail
`CREATE TABLE IF NOT EXISTS "${schemaName}".my_table ...`

Idempotent Migrations

All migrations MUST be idempotent — safe to run multiple times without error.

Creating Tables

CREATE TABLE IF NOT EXISTS "${schema}".my_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL
);

Adding Columns

ALTER TABLE "${schema}".my_table
ADD COLUMN IF NOT EXISTS new_column VARCHAR(100) DEFAULT 'value';

Creating Indexes

CREATE INDEX IF NOT EXISTS idx_my_table_column
ON "${schema}".my_table(column_name);

Adding Constraints

Constraints do not support IF NOT EXISTS natively. Use a DO block:

DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_my_table_user'
) THEN
ALTER TABLE "${schema}".my_table
ADD CONSTRAINT fk_my_table_user
FOREIGN KEY (user_id) REFERENCES "${schema}".users(id);
END IF;
END $$;

Dropping Columns (rare)

ALTER TABLE "${schema}".my_table
DROP COLUMN IF EXISTS old_column;

Running Migrations

cd apps/api
npx ts-node src/scripts/run-tenant-migrations.ts

Expected Output

Starting tenant migrations...
Found 15 tenant schemas
Processing tenant_acme... 2 pending migrations
✓ 025_stage_ownership
✓ 026_add_proposals_table
Processing tenant_corp... 2 pending migrations
✓ 025_stage_ownership
✓ 026_add_proposals_table
...
All migrations complete.

Migration Tracking

Each tenant schema has a schema_migrations table:

SELECT * FROM "tenant_acme".schema_migrations ORDER BY executed_at;
idmigration_nameexecuted_at
1001_initial_schema2025-01-15 10:00:00
2002_add_custom_fields2025-01-15 10:00:01
.........

Existing Migrations Reference

#NameCreates / Modifies
001initial_schemausers, roles, departments, contacts, accounts, leads, opportunities, tasks, activities, audit_logs, notes, documents
002add_custom_fieldscustom_fields, custom_field_groups
003add_pipelinespipelines, pipeline_stages, pipeline_stage_fields
004add_productsproducts, product_categories, price_books, price_book_entries
005add_notificationsnotifications, notification_preferences
006add_teamsteams, team_members, record_teams
007add_user_invitationsuser_invitations
008add_dashboard_widgetsdashboard_widgets, dashboard_layouts
009add_reportsreports, report_folders, report_schedules
010add_calendar_synccalendar_connections, calendar_events
011add_lead_importimport_jobs, import_job_rows
012add_targetstargets, target_assignments, badges, user_badges
013add_lead_scoringlead_scoring_rules
014add_slasla_policies, lead_sla_tracking
015add_contact_accountscontact_accounts (junction table)
016add_lead_productslead_products
017add_opportunity_contactsopportunity_contact_roles
018add_opportunity_line_itemsopportunity_line_items
019add_opportunity_stage_historyopportunity_stage_history
020add_custom_tabscustom_tabs
021add_module_settingsmodule_settings
022add_table_preferencestable_preferences
023add_page_layoutspage_layouts
024add_email_signaturesemail_signatures
025stage_ownershipAdds columns to pipeline_stages + creates record_stage_assignments

Best Practices

Do

  • Always use IF NOT EXISTS / IF EXISTS for idempotency
  • Always index foreign key columns
  • Always index commonly filtered columns (status, type, created_by)
  • Always provide DEFAULT values for new non-nullable columns
  • Always test migrations against an existing tenant schema before merging
  • Include both created_at and updated_at with TIMESTAMPTZ type
  • Include deleted_at TIMESTAMPTZ for soft-deletable tables

Do Not

  • Never edit an already-deployed migration
  • Never use DROP TABLE without IF EXISTS
  • Never add NOT NULL columns without a DEFAULT to tables that have data
  • Never use ${schemaName} — use ${schema}
  • Never create circular foreign key constraints
  • Never add expensive operations (full table scans, data backfills) to migrations without testing on production-size data
Testing Migrations

Before deploying, test your migration against a copy of a production tenant schema:

-- Create a test copy
CREATE SCHEMA "tenant_test" AS SCHEMA CLONE FROM "tenant_production";
-- Run migrations against it
-- Verify data integrity