This document provides comprehensive information about the database migration system used in the Specialization Explorer project.
- Overview
- Quick Start
- Migration System Architecture
- Migration Files Structure
- How Migrations Work
- Creating New Migrations
- Common Migration Patterns
- Running Migrations
- Migration Patterns and Best Practices
The Specialization Explorer project uses a node-pg-migrate system to manage database schema changes with numbered JavaScript files. This provides a reliable, trackable, and automated approach to database schema evolution throughout the project lifecycle.
-
Find the next migration number:
ls cdk/lambda/db_setup/migrations/ | tail -1 # Use next sequential number (e.g., if last is 000, use 001)
-
Create migration file:
# Replace XXX with next number and describe_change with description touch cdk/lambda/db_setup/migrations/XXX_describe_change.js -
Basic migration template:
exports.up = (pgm) => { // Your migration code here pgm.sql(` -- Your SQL here `); }; exports.down = (pgm) => { // Optional: rollback logic };
The migration system is built on node-pg-migrate and consists of:
- Migration Files: Located in
cdk/lambda/db_setup/migrations/with numbered JavaScript files - Migration Runner: The
index.jsfile contains the main migration execution logic - Migration Tracking: Uses a
pgmigrationstable to track which migrations have been applied - Automatic Execution: Runs during CDK deployments via AWS Lambda
cdk/lambda/db_setup/
├── index.js # Main entry point, runs node-pg-migrate
├── initializer.py # Database initialization utilities
└── migrations/ # Migration files directory
├── 000_initial_schema.js
└── ...
Each migration file follows this naming convention: {number}_{description}.js
Example Structure:
exports.up = (pgm) => {
// Forward migration logic
pgm.sql(`
ALTER TABLE users ADD COLUMN new_field VARCHAR(255);
`);
};
exports.down = (pgm) => {
// Rollback logic (optional but recommended)
pgm.sql(`
ALTER TABLE users DROP COLUMN new_field;
`);
};exports.up = (pgm) => {
pgm.createTable("new_table", {
id: {
type: "uuid",
primaryKey: true,
default: pgm.func("uuid_generate_v4()"),
},
name: { type: "varchar", notNull: true },
created_at: { type: "timestamp", default: pgm.func("now()") },
});
};exports.up = (pgm) => {
pgm.addColumn("existing_table", {
new_column: {
type: "jsonb",
default: "{}",
comment: "Description of the column purpose",
},
});
};exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE table_name ALTER COLUMN column_name TYPE new_type;
`);
};- Migration Discovery: The system scans the
migrations/directory for.jsfiles - Sorting: Files are sorted numerically by their prefix (e.g.,
001_,002_) - Tracking Check: Each migration is checked against the
pgmigrationstable - Execution: Only unapplied migrations are executed in order
- Recording: Successfully applied migrations are recorded in the tracking table
The system uses a PostgreSQL table to track applied migrations:
pgmigrations: Used by node-pg-migrate to track which migrations have been successfully applied
graph TD
A[Lambda Function Triggered] --> B[Connect to Database]
B --> C[Run node-pg-migrate]
C --> D[Check Migration Files]
D --> E[Apply Pending Migrations]
E --> F[Create Application Users]
F --> G[Update Secrets Manager]
G --> H[Return Success]
-
Determine the next migration number:
# Look at existing files in migrations/ directory ls cdk/lambda/db_setup/migrations/ # Use the next sequential number
-
Create the migration file:
# Example: Adding audit table touch cdk/lambda/db_setup/migrations/001_add_audit_table.js -
Write the migration:
exports.up = (pgm) => { pgm.createTable("audit_logs", { id: { type: "uuid", primaryKey: true, default: pgm.func("uuid_generate_v4()"), }, created_at: { type: "timestamp", default: pgm.func("now()") }, }); }; exports.down = (pgm) => { pgm.dropTable("audit_logs"); };
exports.up = (pgm) => {
pgm.createTable("table_name", {
id: {
type: "uuid",
primaryKey: true,
default: pgm.func("uuid_generate_v4()"),
},
name: { type: "varchar", notNull: true },
description: { type: "text" },
status: {
type: "varchar",
default: "active",
check: "status IN ('active', 'inactive')",
},
created_at: { type: "timestamp", default: pgm.func("now()") },
updated_at: { type: "timestamp" },
});
// Add indexes
pgm.createIndex("table_name", "name");
};exports.up = (pgm) => {
pgm.addColumns("existing_table", {
new_column: {
type: "varchar",
default: "default_value",
notNull: true,
comment: "Description of the column",
},
optional_column: {
type: "jsonb",
default: "{}",
},
});
};exports.up = (pgm) => {
// Change column type
pgm.sql(`ALTER TABLE table_name ALTER COLUMN column_name TYPE new_type;`);
// Add constraint
pgm.sql(
`ALTER TABLE table_name ADD CONSTRAINT constraint_name CHECK (condition);`
);
// Set default value
pgm.sql(
`ALTER TABLE table_name ALTER COLUMN column_name SET DEFAULT 'value';`
);
};exports.up = (pgm) => {
pgm.addColumns("child_table", {
parent_id: {
type: "uuid",
references: "parent_table(id)",
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
});
// Or add constraint to existing column
pgm.addConstraint("child_table", "fk_parent", {
foreignKeys: {
columns: "parent_id",
references: "parent_table(id)",
onDelete: "SET NULL",
},
});
};exports.up = (pgm) => {
// Simple index
pgm.createIndex("table_name", "column_name");
// Composite index
pgm.createIndex("table_name", ["col1", "col2"], {
name: "idx_custom_name",
});
// Unique index
pgm.createIndex("table_name", "email", {
unique: true,
name: "idx_unique_email",
});
// Partial index
pgm.createIndex("table_name", "status", {
where: "status = 'active'",
name: "idx_active_status",
});
};exports.up = (pgm) => {
// Create ENUM type safely
pgm.sql(`
DO $$ BEGIN
CREATE TYPE status_type AS ENUM ('pending', 'active', 'inactive');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`);
// Use in table
pgm.addColumn("table_name", {
status: {
type: "status_type",
default: "pending",
},
});
};exports.up = (pgm) => {
pgm.sql(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`);
pgm.sql(`CREATE EXTENSION IF NOT EXISTS "vector";`);
};exports.up = (pgm) => {
// Insert default data
pgm.sql(`
INSERT INTO system_settings (max_messages_per_day)
VALUES (45)
WHERE NOT EXISTS (SELECT 1 FROM system_settings);
`);
// Update existing data
pgm.sql(`
UPDATE users
SET messages_sent = 0
WHERE messages_sent IS NULL;
`);
};Migrations run automatically during:
- CDK deployment: When the database setup Lambda function is triggered
- Database initialization: First-time database setup
- Stack updates: When infrastructure changes are deployed
For development or troubleshooting:
# Navigate to the lambda directory
cd cdk/lambda/db_setup
# Install dependencies
npm install
# Run migrations directly using node-pg-migrate
node -e "
const { migrate } = require('node-pg-migrate');
migrate({
databaseUrl: 'postgresql://user:pass@host:5432/db',
dir: './migrations',
direction: 'up'
}).then(() => console.log('Done'));
"// ✅ Safe table creation
CREATE TABLE IF NOT EXISTS table_name (...);
// ✅ Safe column addition with default
pgm.addColumn('table', {
column: { type: 'varchar', default: 'value', notNull: true }
});
// ✅ Safe ENUM creation
DO $$ BEGIN
CREATE TYPE enum_name AS ENUM ('val1', 'val2');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
// ✅ Safe index creation
CREATE INDEX IF NOT EXISTS idx_name ON table (column);// ❌ Dangerous: Non-nullable without default
pgm.addColumn('table', {
required_field: { type: 'varchar', notNull: true } // Will fail on existing rows
});
// ❌ Dangerous: Dropping columns (data loss)
pgm.dropColumn('table', 'column');
// ❌ Dangerous: Dropping tables (data loss)
pgm.dropTable('table');
// ❌ Dangerous: Raw SQL without IF NOT EXISTS
CREATE TABLE table_name (...); // Will fail if table exists- Always use
IF NOT EXISTSfor CREATE statements - Handle duplicate objects gracefully using
DO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN null; END $$; - Test migrations in development before applying to production
// Good: Safe column addition with default
exports.up = (pgm) => {
// Insert default system settings row
pgm.sql(`
INSERT INTO system_settings (
max_messages_per_day,
min_messages_before_suggest,
max_chatacters_per_user_message,
max_chatacters_per_ai_message,
temperature,
top_p,
specialization_list,
updated_by,
updated_at
)
SELECT
45,
4,
2000,
5000,
0.2,
0.9,
ARRAY[]::text[],
NULL,
now()
WHERE NOT EXISTS (SELECT 1 FROM system_settings);
`);
// Backfill existing users message count if needed
pgm.sql(`
UPDATE users
SET messages_sent = 0
WHERE messages_sent IS NULL;
`);
};
// Avoid: Non-nullable column without default
exports.up = (pgm) => {
pgm.addColumn("users", {
required_field: { type: "varchar", notNull: true }, // This will fail!
});
};exports.up = (pgm) => {
// Add table first
pgm.createTable("large_table", {
/* ... */
});
// Then add indexes
pgm.createIndex("large_table", "frequently_queried_column");
pgm.createIndex("large_table", ["composite", "index"], {
name: "idx_composite_key",
});
};// Good: Handle ENUM types safely
exports.up = (pgm) => {
pgm.sql(`
DO $$ BEGIN
CREATE TYPE new_status AS ENUM ('pending', 'active', 'inactive');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`);
};exports.up = (pgm) => {
pgm.createTable("child_table", {
id: { type: "uuid", primaryKey: true },
parent_id: {
type: "uuid",
references: "parent_table(id)",
onDelete: "CASCADE", // or 'SET NULL', 'RESTRICT'
onUpdate: "CASCADE",
},
});
};For migrations involving large datasets:
exports.up = (pgm) => {
// Use batch processing for large updates
pgm.sql(`
UPDATE large_table
SET new_column = old_column
WHERE id IN (
SELECT id FROM large_table
WHERE new_column IS NULL
LIMIT 1000
);
`);
};- Pending: Migration file exists but hasn't been applied
- Applied: Migration has been successfully executed and recorded
- Failed: Migration encountered an error during execution
- Forward-only approach: The system primarily supports forward migrations
- Manual rollbacks: Rollback procedures must be handled manually if needed
- Data loss prevention: Always backup before major schema changes
- Use sequential numbering for migration files
- Always use IF NOT EXISTS for CREATE statements
- Provide defaults when adding NOT NULL columns
- Test migrations on development database first
- Keep migrations focused - one logical change per file
- Add appropriate indexes for new columns that will be queried
- Document complex migrations with comments
- Consider performance for large table modifications
- Plan rollback strategy for critical changes
- Backup production before major schema changes
- Review existing migrations for patterns:
cdk/lambda/db_setup/migrations/ - Test in development environment first
- Ask team for review of complex migrations
- Check PostgreSQL documentation for advanced SQL features
This comprehensive migration system ensures safe, trackable, and reliable database schema evolution throughout the Specialization Explorer project lifecycle.