catalog-backend: wrap MySQL/SQLite migration branches in explicit transactions

The search FK migration uses transaction: false for PostgreSQL's benefit,
but this left MySQL and SQLite branches non-atomic. A failure between
dropForeign and the new addForeign would leave the table with no FK
constraint. Wrap those branches in explicit knex.transaction() calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
Fredrik Adelöw
2026-03-11 14:00:52 +01:00
parent 3644b725f8
commit 1ecf82a5c2
@@ -90,34 +90,41 @@ exports.up = async function up(knex) {
await knex('search').whereIn('entity_id', ids).delete();
}
// Drop old FK and add new one. MySQL does not support NOT VALID, but
// the table is already clean so validation is fast.
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
// Drop old FK and add new one inside an explicit transaction, since the
// global transaction wrapper is disabled for this migration. MySQL does
// not support NOT VALID, but the table is already clean so validation
// is fast.
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await trx.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
});
});
} else {
// SQLite: simple approach, locking is not a concern
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
// SQLite: wrap in an explicit transaction since the global transaction
// wrapper is disabled for this migration.
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await knex('search')
.whereNotIn('entity_id', knex('final_entities').select('entity_id'))
.delete();
await trx('search')
.whereNotIn('entity_id', trx('final_entities').select('entity_id'))
.delete();
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
await trx.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('final_entities')
.onDelete('CASCADE');
});
});
}
};
@@ -177,31 +184,35 @@ exports.down = async function down(knex) {
await knex('search').whereIn('entity_id', ids).delete();
}
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await trx.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
});
});
} else {
await knex.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await knex.transaction(async trx => {
await trx.schema.alterTable('search', table => {
table.dropForeign(['entity_id']);
});
await knex('search')
.whereNotIn('entity_id', knex('refresh_state').select('entity_id'))
.delete();
await trx('search')
.whereNotIn('entity_id', trx('refresh_state').select('entity_id'))
.delete();
await knex.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
await trx.schema.alterTable('search', table => {
table
.foreign('entity_id')
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE');
});
});
}
};