From ec5892dfe9a7f142bf26faf300eebbc0c3c4b873 Mon Sep 17 00:00:00 2001 From: Kevin Gatera Date: Tue, 3 Mar 2026 17:34:36 -0500 Subject: [PATCH] harden migration index handling for postgres replays --- .../v2.15.0-series-column-unique.js | 26 ++++++++--- server/migrations/v2.15.2-index-creation.js | 16 ++++++- .../migrations/v2.15.2-index-creation.test.js | 45 +++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 test/server/migrations/v2.15.2-index-creation.test.js diff --git a/server/migrations/v2.15.0-series-column-unique.js b/server/migrations/v2.15.0-series-column-unique.js index be782225a..883ce09fc 100644 --- a/server/migrations/v2.15.0-series-column-unique.js +++ b/server/migrations/v2.15.0-series-column-unique.js @@ -28,7 +28,12 @@ async function up({ context: { queryInterface, logger } }) { // Check if the unique index already exists const seriesIndexes = await queryInterface.showIndex('Series') - if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { + if ( + seriesIndexes.some((index) => { + const indexName = index?.name || index?.indexName || '' + return String(indexName).toLowerCase() === 'unique_series_name_per_library' + }) + ) { logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists') logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ') return @@ -185,11 +190,20 @@ async function up({ context: { queryInterface, logger } }) { logger.info(`[2.15.0 migration] Deduplication complete`) // Create a unique index based on the name and library ID for the `Series` table - await queryInterface.addIndex('Series', ['name', 'libraryId'], { - unique: true, - name: 'unique_series_name_per_library' - }) - logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId') + try { + await queryInterface.addIndex('Series', ['name', 'libraryId'], { + unique: true, + name: 'unique_series_name_per_library' + }) + logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId') + } catch (error) { + const alreadyExists = + (error?.name === 'SequelizeDatabaseError' && /already exists/i.test(error?.message || '')) || + error?.original?.code === '42P07' + if (!alreadyExists) throw error + + logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists') + } logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ') } diff --git a/server/migrations/v2.15.2-index-creation.js b/server/migrations/v2.15.2-index-creation.js index f1302dd26..5f976495c 100644 --- a/server/migrations/v2.15.2-index-creation.js +++ b/server/migrations/v2.15.2-index-creation.js @@ -41,7 +41,7 @@ async function up({ context: { queryInterface, logger } }) { // Delete existing podcastEpisode index logger.info('[2.15.2 migration] Deleting existing podcastEpisode index') - await queryInterface.removeIndex('podcastEpisodes', 'podcast_episodes_created_at') + await removeIndexIfExists(queryInterface, 'podcastEpisodes', 'podcast_episodes_created_at', logger) // Create index for podcastEpisode and createdAt logger.info('[2.15.2 migration] Creating index for podcastEpisode and createdAt') @@ -78,7 +78,7 @@ async function down({ context: { queryInterface, logger } }) { // Delete existing podcastEpisode index logger.info('[2.15.2 migration] Deleting existing podcastEpisode index') - await queryInterface.removeIndex('podcastEpisodes', 'podcastEpisode_createdAt_podcastId') + await removeIndexIfExists(queryInterface, 'podcastEpisodes', 'podcastEpisode_createdAt_podcastId', logger) // Create index for podcastEpisode and createdAt logger.info('[2.15.2 migration] Creating original index for podcastEpisode createdAt') @@ -91,3 +91,15 @@ async function down({ context: { queryInterface, logger } }) { } module.exports = { up, down } + +async function removeIndexIfExists(queryInterface, tableName, indexName, logger) { + const indexes = await queryInterface.showIndex(tableName) + const hasIndex = indexes.some((index) => String(index?.name || index?.indexName || '').toLowerCase() === indexName.toLowerCase()) + + if (!hasIndex) { + logger.info(`[2.15.2 migration] Index ${indexName} does not exist, skipping removeIndex`) + return + } + + await queryInterface.removeIndex(tableName, indexName) +} diff --git a/test/server/migrations/v2.15.2-index-creation.test.js b/test/server/migrations/v2.15.2-index-creation.test.js new file mode 100644 index 000000000..c69142fcd --- /dev/null +++ b/test/server/migrations/v2.15.2-index-creation.test.js @@ -0,0 +1,45 @@ +const { expect } = require('chai') +const { Sequelize, DataTypes } = require('sequelize') + +const Logger = require('../../../server/Logger') +const { up, down } = require('../../../server/migrations/v2.15.2-index-creation') + +describe('migration-v2.15.2-index-creation', () => { + let sequelize + let queryInterface + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + + await queryInterface.createTable('bookAuthors', { + id: { type: DataTypes.INTEGER, primaryKey: true }, + authorId: { type: DataTypes.INTEGER } + }) + + await queryInterface.createTable('bookSeries', { + id: { type: DataTypes.INTEGER, primaryKey: true }, + seriesId: { type: DataTypes.INTEGER } + }) + + await queryInterface.createTable('podcastEpisodes', { + id: { type: DataTypes.INTEGER, primaryKey: true }, + createdAt: { type: DataTypes.DATE }, + podcastId: { type: DataTypes.INTEGER } + }) + }) + + it('up should succeed when legacy podcast index is missing', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const indexes = await queryInterface.showIndex('podcastEpisodes') + expect(indexes.some((index) => index.name === 'podcastEpisode_createdAt_podcastId')).to.equal(true) + }) + + it('down should succeed when new podcast index is missing', async () => { + await down({ context: { queryInterface, logger: Logger } }) + + const indexes = await queryInterface.showIndex('podcastEpisodes') + expect(indexes.some((index) => index.name === 'podcast_episodes_created_at')).to.equal(true) + }) +})