Database Migrations
CityRP's migration system provides a structured way to evolve your database schema over time while maintaining consistency across different environments. This system ensures that database changes are applied in the correct order and only once per environment.
Overview
The migration system consists of:
- Migration Registry: Tracks which migrations have been executed
- Execution Engine: Runs migrations in chronological order
- File-based Migrations: Timestamped Lua files containing schema changes
- Plugin Integration: Automatic loading from plugin directories
- Rollback Support: Optional down migrations for reverting changes
How It Works
Migration Table
The system uses a single migrations table with two fields:
migration_id: Unique identifier for the migrationcreated_at: Timestamp when the migration was executed
This table is automatically created and is the only table that uses "IF NOT EXISTS" logic.
File Structure
Migration files follow a strict naming convention:
sv_<unix_timestamp>_<descriptive-name>.lua
Examples:
sv_1687120203_create-inventory-table.luasv_1703448501_add-character-system.luasv_1699211263_update-player-constraints.lua
File Locations
Core Migrations:
gamemode/core/migrations/sv_*.lua
Plugin Migrations:
plugins/<plugin_name>/migrations/sv_*.lua
Execution Order
- Core migrations are loaded first
- Plugin migrations are loaded via the
DoPluginIncludeshook - All migrations are sorted by timestamp (oldest first)
- Only unexecuted migrations are run
- Each migration must call
done()to mark completion
Basic Migration Structure
Every migration file must call cityrp.migrations.register() with an object containing up and down functions:
cityrp.migrations.register({
up = function(done)
-- Forward migration logic
print("Applying migration changes")
done() -- MUST call done() when complete
end,
down = function(done)
-- Rollback logic (optional but recommended)
print("Reverting migration changes")
done() -- MUST call done() when complete
end
})
Asynchronous Operations
For operations that use callbacks (like database queries):
cityrp.migrations.register({
up = function(done)
print("Starting database migration")
mysql:RawQuery([[
CREATE TABLE example (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL
);
]], function(results)
print("Migration completed successfully")
done() -- Call done() in the callback
end)
end,
down = function(done)
mysql:RawQuery("DROP TABLE example;", done)
end
})
Common Migration Patterns
Table Creation
cityrp.migrations.register({
up = function(done)
mysql:RawQuery([[
CREATE TABLE characters (
character_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
player_id VARCHAR(17) NOT NULL,
first_name VARCHAR(64) NOT NULL,
last_name VARCHAR(64) NOT NULL,
face VARCHAR(32) NOT NULL,
type VARCHAR(32) NOT NULL DEFAULT 'Citizen',
data JSON,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted TIMESTAMP NULL,
PRIMARY KEY (character_id),
FOREIGN KEY (player_id) REFERENCES players(_SteamID64),
INDEX idx_player_active (player_id, deleted)
);
]], done)
end,
down = function(done)
mysql:RawQuery("DROP TABLE characters;", done)
end
})
Column Addition
cityrp.migrations.register({
up = function(done)
mysql:RawQuery([[
ALTER TABLE players
ADD COLUMN _BankID INT UNSIGNED NULL
AFTER _Inventory;
]], done)
end,
down = function(done)
mysql:RawQuery([[
ALTER TABLE players
DROP COLUMN _BankID;
]], done)
end
})
Index Creation
cityrp.migrations.register({
up = function(done)
mysql:RawQuery([[
CREATE INDEX idx_players_steamid
ON players(_SteamID64);
]], done)
end,
down = function(done)
mysql:RawQuery([[
DROP INDEX idx_players_steamid
ON players;
]], done)
end
})
Foreign Key Management
cityrp.migrations.register({
up = function(done)
-- Get database name dynamically
mysql:RawQuery("SET @database_name = '" .. cityrpserver["MySQL Database"] .. "'; " .. [[
-- Get existing constraint name
SELECT CONSTRAINT_NAME INTO @constraint_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = @database_name
AND TABLE_NAME = 'players'
AND COLUMN_NAME = '_InventoryID';
-- Drop old constraint
SET @sql = CONCAT('ALTER TABLE players DROP FOREIGN KEY ', @constraint_name);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Add new constraint with ON DELETE behavior
ALTER TABLE players
ADD CONSTRAINT players_inventory_fk
FOREIGN KEY (_InventoryID) REFERENCES inventory(inventory_id)
ON DELETE SET NULL;
]], done)
end,
down = function(done)
mysql:RawQuery([[
ALTER TABLE players
DROP FOREIGN KEY players_inventory_fk;
]], done)
end
})
Plugin Migration Patterns
Plugin Table Creation
-- plugins/myplugin/migrations/sv_1234567890_create-plugin-tables.lua cityrp.migrations.register({ up = function(done) mysql:RawQuery([[ CREATE TABLE plugin_data ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, player_id VARCHAR(17) NOT NULL, plugin_value TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), FOREIGN KEY (player_id) REFERENCES players(_SteamID64) ON DELETE CASCADE, INDEX idx_player_id (player_id) ); ]], done) end, down = function(done) mysql:RawQuery("DROP TABLE plugin_data;", done) end })
Plugin Configuration Migration
-- plugins/economy/migrations/sv_1234567891_add-economy-config.lua cityrp.migrations.register({ up = function(done) mysql:RawQuery([[ CREATE TABLE economy_config ( config_key VARCHAR(64) NOT NULL, config_value TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (config_key) ); ]], function() -- Insert default configuration mysql:RawQuery([[ INSERT INTO economy_config (config_key, config_value) VALUES ('starting_money', '5000'), ('salary_interval', '300'), ('max_bank_interest', '0.05'); ]], done) end) end, down = function(done) mysql:RawQuery("DROP TABLE economy_config;", done) end })
Data Migration
-- Migrate existing data to new format cityrp.migrations.register({ up = function(done) -- First, create new table mysql:RawQuery([[ CREATE TABLE inventory_v2 ( inventory_id INT UNSIGNED NOT NULL AUTO_INCREMENT, owner_id VARCHAR(17) NOT NULL, inventory_type VARCHAR(32) NOT NULL DEFAULT 'player', max_size INT UNSIGNED NOT NULL DEFAULT 50, data JSON, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (inventory_id), INDEX idx_owner (owner_id, inventory_type) ); ]], function() -- Migrate existing inventory data mysql:RawQuery([[ INSERT INTO inventory_v2 (owner_id, inventory_type, max_size, data) SELECT _SteamID64, 'player', 50, JSON_OBJECT('legacy_inventory', _Inventory) FROM players WHERE _Inventory IS NOT NULL; ]], done) end) end, down = function(done) mysql:RawQuery("DROP TABLE inventory_v2;", done) end })
Best Practices
Migration Design
Do:
- Use descriptive migration names that explain the change
- Include rollback logic in
downfunctions - Use proper foreign key constraints with appropriate ON DELETE/UPDATE behavior
- Test migrations on development database before deployment
- Use dynamic constraint name detection for complex operations
- Always call
done()even for synchronous operations
Don't:
- Modify existing migration files after they've been deployed
- Use hardcoded constraint names (they may vary between environments)
- Forget to call
done()callback - Create migrations that depend on application code
- Use manual transactions (the system handles this)
Performance Considerations
-- For large data migrations, process in batches cityrp.migrations.register({ up = function(done) local function ProcessBatch(offset) mysql:RawQuery(string.format([[ UPDATE large_table SET new_column = CONCAT('prefix_', old_column) WHERE id > %d AND id <= %d; ]], offset, offset + 1000), function() -- Check if more rows need processing mysql:RawQuery(string.format([[ SELECT COUNT(*) as count FROM large_table WHERE id > %d AND new_column IS NULL; ]], offset + 1000), function(results) if results[1].count > 0 then ProcessBatch(offset + 1000) else done() end end) end) end ProcessBatch(0) end, down = function(done) mysql:RawQuery("UPDATE large_table SET new_column = NULL;", done) end })
Error Handling
cityrp.migrations.register({
up = function(done)
mysql:RawQuery([[
CREATE TABLE example (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL
);
]], function(results, error)
if error then
ErrorNoHalt("Migration failed: " .. error)
-- Still call done() to prevent hanging
done()
return
end
print("Migration completed successfully")
done()
end)
end,
down = function(done)
mysql:RawQuery("DROP TABLE example;", done)
end
})
Troubleshooting
Common Issues
Migration not running:
- Check file naming convention (
sv_timestamp_name.lua) - Verify file is in correct location
- Ensure migration isn't already in migrations table
Migration hanging:
- Verify
done()is called in all code paths - Check for syntax errors in SQL
- Ensure database connection is active
Foreign key errors:
- Use dynamic constraint name detection
- Check that referenced tables exist
- Verify column types match between tables
Debug Tips
-- Add logging to migrations cityrp.migrations.register({ up = function(done) print("Starting migration: create_example_table") mysql:RawQuery([[ CREATE TABLE example (id INT PRIMARY KEY); ]], function(results, error) if error then print("Migration error: " .. error) else print("Migration completed successfully") end done() end) end, down = function(done) print("Rolling back migration: create_example_table") mysql:RawQuery("DROP TABLE example;", done) end })
Manual Migration Management
-- Check migration status SELECT * FROM migrations ORDER BY created_at DESC; -- Manually mark migration as complete (use with caution) INSERT INTO migrations (migration_id, created_at) VALUES ('1234567890_example-migration', NOW()); -- Remove migration record to re-run (dangerous) DELETE FROM migrations WHERE migration_id = '1234567890_example-migration';
Hooks and Events
Available Hooks
-- Called when all migrations complete hook.Add("MigrationsCompleted", "MyPlugin.PostMigration", function() print("All migrations completed, initializing plugin data") -- Perform post-migration setup end) -- Called during plugin loading (used internally) hook.Add("DoPluginIncludes", "Migrations.LoadPluginMigrations", function() -- Plugin migrations are loaded here end)
Custom Migration Hooks
-- Add custom logic after specific migrations hook.Add("MigrationsCompleted", "CustomSetup", function() -- Check if specific migration has run mysql:RawQuery([[ SELECT 1 FROM migrations WHERE migration_id = '1234567890_character-system' ]], function(results) if #results > 0 then -- Character system migration has run, set up defaults print("Setting up default character data") end end) end)