fix: prevent database migration restart failures

- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
This commit is contained in:
Paul Nothaft
2026-01-15 15:43:13 +01:00
parent ce8587b24d
commit 83a4344a01
4 changed files with 81 additions and 44 deletions
@@ -1,22 +1,27 @@
exports.up = async function(knex) { exports.up = async function(knex) {
// Add photo_counter column to photo_categories table // Check if photo_counter column already exists to make migration idempotent
await knex.schema.alterTable('photo_categories', function(table) { const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos if (!hasPhotoCounter) {
const categories = await knex('photo_categories').select('id'); // Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
for (const category of categories) { table.integer('photo_counter').defaultTo(0).notNullable();
const photoCount = await knex('photos') });
.where('category_id', category.id)
.count('id as count') // Initialize counters based on existing photos
.first(); const categories = await knex('photo_categories').select('id');
if (photoCount && photoCount.count > 0) { for (const category of categories) {
await knex('photo_categories') const photoCount = await knex('photos')
.where('id', category.id) .where('category_id', category.id)
.update({ photo_counter: photoCount.count }); .count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
} }
} }
}; };
@@ -1,23 +1,33 @@
exports.up = async function(knex) { exports.up = async function(knex) {
// Add language-specific columns to email_templates // Check which columns already exist to make migration idempotent
await knex.schema.alterTable('email_templates', function(table) { const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
// Add English versions (rename existing columns for consistency) const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
table.renameColumn('subject', 'subject_en'); const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults // Only rename columns if they haven't been renamed yet
await knex('email_templates').update({ if (hasSubjectOriginal && !hasSubjectEn) {
subject_de: knex.raw('subject_en'), await knex.schema.alterTable('email_templates', function(table) {
body_html_de: knex.raw('body_html_en'), table.renameColumn('subject', 'subject_en');
body_text_de: knex.raw('body_text_en') table.renameColumn('body_html', 'body_html_en');
}); table.renameColumn('body_text', 'body_text_en');
});
}
// Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
}; };
exports.down = async function(knex) { exports.down = async function(knex) {
+17 -6
View File
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath); const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath); const migration = require(migrationPath);
const filename = path.basename(filepath); const filename = path.basename(filepath);
if (migration.up) { if (migration.up) {
console.log(`Running migration: ${filepath}`); console.log(`Running migration: ${filepath}`);
// Run migration in a transaction if possible // Run migration in a transaction if possible
// IMPORTANT: Include the migrations table insert INSIDE the transaction
// to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') { if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await migration.up(trx); await migration.up(trx);
// Insert migration record inside transaction for atomicity
await trx('migrations').insert({ filename });
}); });
} else { } else {
await migration.up(db); await migration.up(db);
await db('migrations').insert({ filename });
} }
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed successfully`); console.log(`Migration ${filepath} completed successfully`);
} }
} catch (error) { } catch (error) {
// Check if error is because schema already exists // Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists // PostgreSQL error codes:
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) { // - 42P07: duplicate_table (relation already exists)
// - 42701: duplicate_column (column already exists)
// - 42710: duplicate_object (constraint, index, etc. already exists)
// - 23505: unique_violation (migration record already exists)
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`); console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath)); await markMigrationAsApplied(path.basename(filepath));
} else { } else {
+14 -3
View File
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath); const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath); const migration = require(migrationPath);
const filename = path.basename(filepath); const filename = path.basename(filepath);
if (migration.up) { if (migration.up) {
console.log(`Running migration: ${filepath}`); console.log(`Running migration: ${filepath}`);
await migration.up(db);
await db('migrations').insert({ filename }); // Run migration in a transaction if PostgreSQL to ensure atomicity
// between schema changes and migration tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`); console.log(`Migration ${filepath} completed`);
} }
} }