Skip to main content

Database Migrations

Migrations provide version-controlled schema changes for your database. Every schema operation in Ductape automatically creates a migration, ensuring all changes are tracked, reproducible, and reversible across all environments.

Runtime defaults

When using ductape.databases from a Ductape instance, set product and env on the constructor and pass only database to connect(). Examples below use DatabaseService directly; pass product and env on connect() only if you are not using a Ductape client with constructor defaults.

Prefer the CLI for schema management

For most projects, ductape db schema generate and ductape db migrate are the recommended way to manage your database schema. Declare tables in ductape/database/schema.json, generate version-controlled migration files, and apply them to each environment with a single command. The programmatic API below is available for advanced use cases where you need migration logic embedded in application code.

Quick Example

import { DatabaseService } from '@ductape/sdk';

const db = new DatabaseService();
await db.connect({ database: 'main-db' });

// Create table - automatically generates and applies a migration
const result = await db.schema.create('users', {
id: { type: 'uuid', primaryKey: true },
email: { type: 'string', length: 255, required: true, unique: true },
name: { type: 'string', length: 100 },
}, { timestamps: true });

// The migration is tracked and can be replayed
console.log('Migration:', result.migration.tag);

How It Works

Unlike traditional ORMs where you write migration files manually, Ductape uses a migration-first approach:

  1. Every schema change creates a migration - When you call db.schema.create(), db.schema.addField(), etc., a migration is automatically generated
  2. Migrations are applied immediately - By default, the migration runs on the connected environment
  3. Migrations are tracked - All applied migrations are stored in a migrations table
  4. Platform-independent - The same migration works across PostgreSQL, MySQL, MongoDB, DynamoDB, Cassandra, and MariaDB
Your Code                  Generated Migration           Database
↓ ↓ ↓
db.schema.create() → { type: 'createCollection' } → CREATE TABLE
db.schema.addField() → { type: 'addField' } → ALTER TABLE ADD
db.schema.dropIndex() → { type: 'dropIndex' } → DROP INDEX

Why This Approach?

  • No migration files to manage - Migrations are generated from your intent
  • Type-safe - Schema definitions are validated at compile time
  • Cross-database - Same API works for SQL and NoSQL databases
  • Automatic rollback - Every migration includes down operations
  • Environment-aware - Apply to dev, staging, or production

Schema Operations (Automatic Migrations)

Create Table

await db.schema.create('products', {
id: { type: 'uuid', primaryKey: true },
name: { type: 'string', length: 255, required: true },
price: { type: 'decimal', precision: 10, scale: 2 },
stock: 'integer',
is_active: { type: 'boolean', default: true },
metadata: 'json',
}, { timestamps: true });

// Generated migration:
// {
// type: 'createCollection',
// name: 'products',
// fields: [...],
// up: [...],
// down: [{ type: 'dropCollection', name: 'products' }]
// }

Add Field

await db.schema.addField('users', 'phone', {
type: 'string',
length: 20,
});

// Generated migration:
// {
// type: 'addField',
// collection: 'users',
// field: { name: 'phone', type: 'string', length: 20 },
// down: [{ type: 'dropField', collection: 'users', field: 'phone' }]
// }

Drop Field

await db.schema.dropField('users', 'old_column');

Rename Field

await db.schema.renameField('users', 'name', 'full_name');

Modify Field

await db.schema.modifyField('users', 'email', {
length: 500,
required: true,
});

Index management

Create Index

await db.schema.createIndex('users', ['email'], {
unique: true,
name: 'idx_users_email',
});

Drop Index

await db.schema.dropIndex('users', 'idx_users_old');

Add Constraint (SQL)

await db.schema.addConstraint('posts', {
name: 'fk_posts_author',
type: 'foreignKey',
columns: ['author_id'],
references: {
table: 'users',
columns: ['id'],
onDelete: 'CASCADE',
},
});

Drop Table

await db.schema.drop('old_table', { cascade: true });

Advanced: Migration Builder

For complex scenarios where you need to batch multiple operations or have more control, use the MigrationBuilder:

import { MigrationBuilder, migration } from '@ductape/sdk';

// Fluent API for building migrations
const usersMigration = migration('create_users_with_indexes')
.description('Create users table with all indexes')
.createCollection('users', [
{ name: 'id', type: 'uuid', primaryKey: true },
{ name: 'email', type: 'string', length: 255, unique: true },
{ name: 'name', type: 'string', length: 100 },
{ name: 'status', type: 'enum', enumValues: ['active', 'inactive'] },
])
.createIndex('users', 'idx_users_email', [{ name: 'email' }], { unique: true })
.createIndex('users', 'idx_users_status', [{ name: 'status' }])
.build();

// Apply the migration
const engine = new MigrationEngine(db.getAdapter());
await engine.up(usersMigration);

Batch Multiple Operations

const orderSystemMigration = migration('create_order_system')
.description('Create orders and order_items tables')
.createCollection('orders', [
{ name: 'id', type: 'uuid', primaryKey: true },
{ name: 'customer_id', type: 'uuid', nullable: false },
{ name: 'status', type: 'string', length: 50 },
{ name: 'total', type: 'decimal', precision: 10, scale: 2 },
])
.createCollection('order_items', [
{ name: 'id', type: 'uuid', primaryKey: true },
{ name: 'order_id', type: 'uuid', nullable: false },
{ name: 'product_id', type: 'uuid', nullable: false },
{ name: 'quantity', type: 'integer' },
{ name: 'unit_price', type: 'decimal', precision: 10, scale: 2 },
])
.createIndex('orders', 'idx_orders_customer', [{ name: 'customer_id' }])
.createIndex('order_items', 'idx_order_items_order', [{ name: 'order_id' }])
.build();

Migration Engine

The MigrationEngine handles executing migrations directly:

import { MigrationEngine } from '@ductape/sdk';

const engine = new MigrationEngine(db.getAdapter());

// Run migration up
await engine.up(migration);

// Run migration down (rollback)
await engine.down(migration);

// Get migration history
const history = await engine.getHistory();

// Check migration status
const status = await engine.getStatus({
definedMigrations: [migration1, migration2, migration3],
});

console.log('Pending migrations:', status.pending);
console.log('Completed migrations:', status.completed);

Field Types

Platform-independent field types that work across all databases:

TypeDescriptionSQLMongoDBDynamoDBCassandra
integerWhole numbersINTNumberNint
bigintLarge integersBIGINTLongNbigint
smallintSmall integersSMALLINTNumberNsmallint
floatFloating pointFLOATDoubleNfloat
doubleDouble precisionDOUBLEDoubleNdouble
decimalExact decimalDECIMALDecimal128Ndecimal
stringVariable textVARCHARStringStext
textLong textTEXTStringStext
booleanTrue/falseBOOLEANBooleanBOOLboolean
dateDate onlyDATEDateSdate
timeTime onlyTIMEStringStime
datetimeDate and timeDATETIMEDateStimestamp
timestampTimestampTIMESTAMPDateNtimestamp
jsonJSON dataJSON/JSONBObjectMtext
uuidUUIDUUIDStringSuuid
enumEnumerationENUMStringStext
arrayArray typeARRAYArrayLlist
binaryBinary dataBYTEABinDataBblob
blobLarge binaryBLOBBinDataBblob

Database-Specific Migrations

SQL Databases (PostgreSQL, MySQL, MariaDB)

await db.schema.create('users', {
id: { type: 'uuid', primaryKey: true },
email: { type: 'string', required: true },
}, {
sqlOptions: {
ifNotExists: true,
temporary: false,
unlogged: false, // PostgreSQL only
},
});

// Add constraint
await db.schema.addConstraint('orders', {
name: 'fk_orders_user',
type: 'foreignKey',
columns: ['user_id'],
references: {
table: 'users',
columns: ['id'],
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
});

MongoDB

await db.schema.create('users', {
_id: { type: 'uuid', primaryKey: true },
email: { type: 'string', required: true },
}, {
mongoOptions: {
capped: false,
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['email'],
properties: {
email: { bsonType: 'string' }
}
}
},
validationLevel: 'strict',
validationAction: 'error',
},
});

// Shard a collection
// (handled via raw migration operations)

DynamoDB

await db.schema.create('users', {
id: { type: 'string', primaryKey: true },
email: 'string',
created_at: 'timestamp',
}, {
dynamoOptions: {
partitionKey: { name: 'id', type: 'S' },
sortKey: { name: 'created_at', type: 'N' },
billingMode: 'PAY_PER_REQUEST',
globalSecondaryIndexes: [{
name: 'email-index',
partitionKey: { name: 'email', type: 'S' },
projection: 'ALL',
}],
localSecondaryIndexes: [{
name: 'created-index',
sortKey: { name: 'created_at', type: 'N' },
projection: 'KEYS_ONLY',
}],
streamEnabled: true,
streamViewType: 'NEW_AND_OLD_IMAGES',
ttlAttribute: 'expires_at',
},
});

Cassandra

await db.schema.create('events', {
id: { type: 'uuid', primaryKey: true },
user_id: 'uuid',
event_type: 'string',
data: 'json',
created_at: 'timestamp',
}, {
cassandraOptions: {
partitionKey: ['user_id'],
clusteringColumns: ['created_at', 'id'],
clusteringOrder: [
{ column: 'created_at', order: 'DESC' },
{ column: 'id', order: 'ASC' },
],
defaultTTL: 86400,
compaction: {
class: 'LeveledCompactionStrategy',
},
},
});

Migration History

View Migration History

const engine = new MigrationEngine(db.getAdapter());
const history = await engine.getHistory();

history.forEach((entry) => {
console.log('Tag:', entry.tag);
console.log('Name:', entry.name);
console.log('Applied at:', entry.appliedAt);
console.log('Checksum:', entry.checksum);
console.log('---');
});

Check Migration Status

const status = await engine.getStatus({
definedMigrations: allMigrations,
});

console.log('Total:', status.total);
console.log('Completed:', status.completed);
console.log('Pending:', status.pending);
console.log('Last applied:', status.lastApplied?.name);

// List pending migrations
status.pendingMigrations.forEach((m) => {
console.log('Pending:', m.name);
});

Rolling Back Migrations

Rollback Last Migration

const engine = new MigrationEngine(db.getAdapter());
await engine.down(lastMigration);

Rollback to Specific Point

// Get history and find the target
const history = await engine.getHistory();
const targetIndex = history.findIndex(h => h.tag === 'target_migration_tag');

// Rollback each migration after the target
for (let i = history.length - 1; i > targetIndex; i--) {
await engine.down(history[i].migration);
}

Raw Operations

For database-specific operations not covered by the standard API:

const rawMigration = migration('custom_operation')
.raw('postgresql', 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
.raw('postgresql', 'CREATE INDEX CONCURRENTLY idx_users_email ON users(email)')
.build();

Best Practices

1. Keep Migrations Small

Create separate migrations for each logical change:

// Good: Separate migrations
await db.schema.create('users', { ... });
await db.schema.create('posts', { ... });
await db.schema.addField('users', 'avatar_url', 'string');

// Avoid: One giant migration with everything

2. Test in Development First

Always run migrations in development before production:

// Development
await db.connect({ database: 'main-db' });
await db.schema.create('new_table', { ... });

// After testing, apply to production
await db.connect({ database: 'main-db' });
await db.schema.create('new_table', { ... });

3. Use Descriptive Names

migration('add_phone_and_address_to_users')
migration('create_order_fulfillment_tables')
migration('add_composite_index_on_orders')

4. Include Both Up and Down

The simplified API automatically generates rollback operations, but verify they're correct for complex changes:

const result = await db.schema.addField('users', 'phone', 'string');

// Verify the down migration
console.log(result.migration.down);
// Should include: { type: 'dropField', collection: 'users', field: 'phone' }

5. Handle Data Migrations Separately

For data migrations, use the query API:

// Schema migration
await db.schema.addField('users', 'full_name', 'string');

// Data migration (separate operation)
await db.update({
table: 'users',
data: {
full_name: { $CONCAT: ['first_name', ' ', 'last_name'] }
},
where: { full_name: null },
});

// Clean up old columns
await db.schema.dropField('users', 'first_name');
await db.schema.dropField('users', 'last_name');

Migration Helpers

The MigrationHelpers class provides utilities for common operations:

import { MigrationHelpers } from '@ductape/sdk';

// Generate a unique migration tag
const tag = MigrationHelpers.generateTag('create_users');
// "20240115_143052_create_users"

// Validate a migration
const isValid = MigrationHelpers.validate(migration);

// Calculate migration checksum
const checksum = MigrationHelpers.checksum(migration);

Troubleshooting

Migration Already Applied

try {
await db.schema.create('users', { ... });
} catch (error) {
if (error.message.includes('already exists')) {
console.log('Migration already applied, skipping');
}
}

Failed Migration

If a migration fails partway through:

  1. Check the migration history to see what was applied
  2. Manually fix the database state if needed
  3. Mark the migration as failed or remove it from history
  4. Re-run the migration

Different Environments Out of Sync

// Get status for each environment
for (const env of ['dev', 'staging', 'prd']) {
await db.connect({ env, database: 'main-db' });
const status = await engine.getStatus({ definedMigrations: allMigrations });
console.log(`${env}: ${status.completed}/${status.total} migrations applied`);
}

Managing migrations with the CLI

The CLI provides a file-based migration workflow that is version-control friendly and environment-safe. It is the recommended approach for team projects.

1. Declare your schema

Add your tables to ductape/database/schema.json in your project:

[
{
"db": "main-db",
"tables": {
"users": {
"id": { "type": "String", "primaryKey": true, "autoGenerate": true },
"email": { "type": "String", "required": true, "unique": true },
"name": { "type": "String", "required": true },
"createdAt": { "type": "Date", "default": "now" }
}
}
}
]

2. Generate migration files

ductape db schema generate

This diffs schema.json against any existing migration files and writes new ones to ductape/database/migrations/<db-tag>/. Nothing is applied to the database at this step.

3. Apply migrations

# Apply to your linked environment (from ductape/config.json)
ductape db migrate

# Apply to a specific environment
ductape db migrate --env staging
ductape db migrate --env prd

The MigrationEngine tracks applied migrations in _ductape_migrations. Re-running is safe — already-applied migrations are skipped.

4. Check status and roll back

ductape db migrate status

ductape db migrate rollback # roll back the last migration
ductape db migrate rollback -n 3 # roll back the last 3

When to use the programmatic API instead

The SDK's db.schema.* and MigrationBuilder API (documented above) is appropriate when:

  • You need to generate a migration at runtime based on dynamic application logic
  • You are building tooling or admin scripts that manage schema as part of a larger workflow
  • You need database-specific options (e.g., DynamoDB GSI configuration, Cassandra partition keys) that are not yet expressible in schema.json

For everything else — including all production deployments — prefer the CLI workflow so that migrations are committed to version control and applied explicitly per environment.

See CLI: Database runtime & migrations for the full command reference.

Next Steps

See Also