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,4 +1,8 @@
exports.up = async function(knex) { exports.up = async function(knex) {
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
if (!hasPhotoCounter) {
// Add photo_counter column to photo_categories table // Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) { await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable(); table.integer('photo_counter').defaultTo(0).notNullable();
@@ -19,6 +23,7 @@ exports.up = async function(knex) {
.update({ photo_counter: photoCount.count }); .update({ photo_counter: photoCount.count });
} }
} }
}
}; };
exports.down = async function(knex) { exports.down = async function(knex) {
@@ -1,12 +1,21 @@
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
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
// Only rename columns if they haven't been renamed yet
if (hasSubjectOriginal && !hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) { await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en'); table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en'); table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en'); table.renameColumn('body_text', 'body_text_en');
});
}
// Add German versions // Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de'); table.string('subject_de');
table.text('body_html_de'); table.text('body_html_de');
table.text('body_text_de'); table.text('body_text_de');
@@ -18,6 +27,7 @@ exports.up = async function(knex) {
body_html_de: knex.raw('body_html_en'), body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en') body_text_de: knex.raw('body_text_en')
}); });
}
}; };
exports.down = async function(knex) { exports.down = async function(knex) {
+14 -3
View File
@@ -72,21 +72,32 @@ async function runMigrationSafely(filepath) {
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 {
+11
View File
@@ -29,8 +29,19 @@ async function runMigration(filepath) {
if (migration.up) { if (migration.up) {
console.log(`Running migration: ${filepath}`); console.log(`Running migration: ${filepath}`);
// 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 migration.up(db);
await db('migrations').insert({ filename }); await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`); console.log(`Migration ${filepath} completed`);
} }
} }