text: var async = require('async');
  var _ = require('lodash');
  var extend = require('extend');
  var moment = require('moment');
  var fs = require('fs');
  
  /**
   * migration
   * @augments Augments the apos object with methods for
   * adding migrations to be run by the apostrophe:migrate task.
   */
  
  module.exports = function(self) {
    self._migrations = {};
  
    // Add a migration method to be invoked when the
    // apostrophe:migrate task is run. Migrations are run
    // in the order registered. If a migration has been
    // run before for this database, it is not run again.
    // Migrations are not for routine cleanup, use a separate
    // task for that.
    //
    // Migrations MUST tolerate being run more than once with
    // NO ill effects.
    //
    // All migrations WILL run once on brand-new sites the first
    // time the task is run.
    //
    // The cache check which eliminates running a migration
    // again based on its name should be regarded as a
    // performance optimization only.
    //
    // The `options` object is not required.
    //
    // if your migration is time-consuming, and is also safe to run
    // while a previous deployment of the site is already up, set
    // `options.safe` to `true`. This allows it to be run by
    // `apostrophe:migrate --safe`, which is run before
    // the previous deployment is shut down. This shortens
    // downtime during deployments.
    //
    // Your function is invoked with a callback, which expects
    // the usual err parameter. If you do not do any asynchronous
    // work, then you MUST wait until next tick before invoking the
    // callback or use setImmediate(callback).
  
    self.addMigration = function(name, fn, options) {
      self._migrations[name] = { fn: fn, options: options || {} };
    };
  
    // Perform all migrations when the apostrophe:migrate task is run
    self.migrate = function(argv, callback) {
      console.log('Migrating...');
      var cache = self.getCache('migrations');
      return async.eachSeries(_.keys(self._migrations), function(name, migrationCallback) {
  
        if (argv['force-one'] && (argv['force-one'] !== name)) {
          return setImmediate(migrationCallback);
        }
  
        var migration = self._migrations[name];
  
        if (argv.safe && (!migration.options.safe)) {
          return setImmediate(migrationCallback);
        }
  
        return async.series({
          cacheCheck: function(callback) {
            if (argv.force || argv['force-one']) {
              // Run them all if --force is specified
              return setImmediate(callback);
            }
            return cache.get(name, function(err, val) {
              if (err) {
                console.log(err);
                return callback(err);
              }
              if (val) {
                return setImmediate(migrationCallback);
              }
              // Cache miss
              return setImmediate(callback);
            });
          },
          runMigration: function(callback) {
            console.log('Running migration: ' + name);
            return migration.fn(callback);
          },
          cacheSet: function(callback) {
            return cache.set(name, true, callback);
          }
        }, migrationCallback);
      }, function(err) {
        if (!err) {
          console.log('Done.');
        }
        return callback(err);
      });
    };
  
    // Add the core migrations now so they get on the list before
    // any module and project-specific migrations
  
    self.addMigration('addTrash', function addTrash(callback) {
      // ISSUE: old sites might not have a trashcan page as a parent for trashed pages.
      self.pages.findOne({ type: 'trash', trash: true }, function (err, trash) {
        if (err) {
          return callback(err);
        }
        if (!trash) {
          console.log('No trash, adding it');
          return self.insertSystemPage({
            _id: 'trash',
            path: 'home/trash',
            slug: '/trash',
            type: 'trash',
            title: 'Trash',
            // Max home page direct kids on one site: 1 million. Max special
            // purpose admin pages: 999. That ought to be enough for
            // anybody... I hope!
            rank: 1000999,
            trash: true,
          }, callback);
        }
        return callback(null);
      });
    });
  
    self.addMigration('trimTitle', function trimTitle(callback) {
      return self.forEachPage({ $or: [ { title: /^ / }, { title: / $/ } ] },
        function(page, callback) {
          return self.pages.update(
            { _id: page._id },
            { $set: { title: page.title.trim() } },
            callback);
        },
        callback);
    });
  
    self.addMigration('trimSlug', function trimSlug(callback) {
      return self.forEachPage({ $or: [ { slug: /^ / }, { slug: / $/ } ] },
        function(page, callback) {
          return self.pages.update(
            { _id: page._id },
            { $set: { slug: page.slug.trim() } },
            callback);
        },
        callback);
    });
  
    self.addMigration('fixSortTitle', function fixSortTitle(callback) {
      return self.forEachPage({ $or: [ { sortTitle: { $exists: 0 } }, { sortTitle: /^ / }, { sortTitle: / $/} ] },
        function(page, callback) {
          if (!page.title) {
            // Virtual pages will do this. Don't crash.
            return callback(null);
          }
          return self.pages.update(
            { _id: page._id },
            { $set: { sortTitle: self.sortify(page.title.trim()) } },
            callback);
        },
        callback);
    });
  
    // A2 uses plain strings as IDs. This allows true JSON serialization and
    // also allows known IDs to be used as identifiers which simplifies writing
    // importers from other CMSes. If someone who doesn't realize this plorps a lot
    // of ObjectIDs into the pages collection by accident, clean up the mess.
  
    self.addMigration('fixObjectId', function fixObjectId(callback) {
      return self.forEachPage({},
        function(page, callback) {
          var id = page._id;
          // Convert to an actual hex string, see if that makes it different, if so
          // save it with the new hex string as its ID. We have to remove and reinsert
          // it, unfortunately.
          page._id = id.toString();
          if (id !== page._id) {
            return self.pages.remove({ _id: id }, function(err) {
              if (err) {
                return callback(err);
              }
              return self.pages.insert(page, callback);
            });
          } else {
            return callback(null);
          }
        },
        callback
      );
    });
  
    self.addMigration('explodePublishedAt', function explodePublishedAt(callback) {
      // the publishedAt property of articles must also be available in
      // the form of two more easily edited fields, publicationDate and
      // publicationTime
      var used = false;
      self.forEachPage({ type: 'blogPost' }, function(page, callback) {
        if ((page.publishedAt !== undefined) && (page.publicationDate === undefined)) {
          if (!used) {
            console.log('setting publication date and time for posts');
            used = true;
          }
          page.publicationDate = moment(page.publishedAt).format('YYYY-MM-DD');
          page.publicationTime = moment(page.publishedAt).format('HH:mm');
          return self.pages.update(
            { _id: page._id },
            { $set: { publicationDate: page.publicationDate, publicationTime: page.publicationTime } },
            callback);
        } else {
          return callback(null);
        }
      }, callback);
    });
  
    self.addMigration('missingImageData', function missingImageMetadata(callback) {
      var n = 0;
      return self.forEachFile({ $or: [
          { md5: { $exists: 0 } },
          { $and:
            [
              { extension: { $in: [ 'jpg', 'gif', 'png' ] } },
              { width: { $exists: 0 } }
            ]
          }
        ] }, function(file, callback) {
        var originalFile = '/files/' + file._id + '-' + file.name + '.' + file.extension;
        var tempFile = self.uploadfs.getTempPath() + '/' + self.generateId() + '.' + file.extension;
        n++;
        if (n === 1) {
          console.log('Adding metadata for files (may take a while)...');
        }
        async.series([
          function(callback) {
            self.uploadfs.copyOut(originalFile, tempFile, callback);
          },
          function(callback) {
            return self.md5File(tempFile, function(err, result) {
              if (err) {
                return callback(err);
              }
              file.md5 = result;
              return callback(null);
            });
          },
          function(callback) {
            if (_.contains(['gif', 'jpg', 'png'], file.extension) && (!file.width)) {
              return self.uploadfs.identifyLocalImage(tempFile, function(err, info) {
                if (err) {
                  return callback(err);
                }
                file.width = info.width;
                file.height = info.height;
                if (file.width > file.height) {
                  file.landscape = true;
                } else {
                  file.portrait = true;
                }
                return callback(null);
              });
            } else {
              return callback(null);
            }
          },
          function(callback) {
            self.files.update({ _id: file._id }, file, { safe: true }, callback);
          },
          function(callback) {
            fs.unlink(tempFile, callback);
          }
        ], function(err) {
          if (err) {
            // Don't give up completely if a file is gone or bad
            console.log('WARNING: error on ' + originalFile);
          }
          return callback(null);
        });
      }, callback);
    });
  
    self.addMigration('missingFileSearch', function missingFileSearch(callback) {
      var n = 0;
      return self.forEachFile({ searchText: { $exists: 0 } }, function(file, callback) {
        n++;
        if (n === 1) {
          console.log('Adding searchText to files...');
        }
        file.searchText = self.fileSearchText(file);
        self.files.update({ _id: file._id }, file, callback);
      }, callback);
    });
  
    // If there are any pages whose tags property is defined but set
    // to null, due to inadequate sanitization in the snippets module,
    // fix them to be empty arrays so templates don't crash
    self.addMigration('fixNullTags', function fixNullTags(callback) {
      return self.pages.findOne({ $and: [ { tags: null }, { tags: { $exists: true } } ] }, function(err, page) {
        if (err) {
          return callback(err);
        }
        if (!page) {
          return callback(null);
        }
        console.log('Fixing pages whose tags property is defined and set to null');
        return self.pages.update({ $and: [ { tags: null }, { tags: { $exists: true } } ] }, { $set: { tags: [] }}, { multi: true }, callback);
      });
    });
  
    // Tags that are numbers can be a consequence of an import.
    // Clean that up so they match regexes properly.
    self.addMigration('fixNumberTags', function fixNumberTags(callback) {
      return self.pages.distinct("tags", {}, function(err, tags) {
        if (err) {
          return callback(err);
        }
        return async.eachSeries(tags, function(tag, callback) {
          if (typeof(tag) === 'number') {
            return self.forEachPage({ tags: { $in: [ tag ] } }, function(page, callback) {
              page.tags = _.without(page.tags, tag);
              page.tags.push(tag.toString());
              return self.pages.update({ slug: page.slug }, { $set: { tags: page.tags } }, callback);
            }, callback);
          } else {
            return callback(null);
          }
        }, callback);
      });
    });
  
    self.addMigration('fixTimelessEvents', function fixTimelessEvents(callback) {
      var used = false;
      return self.forEachPage({ type: 'event' }, function(page, callback) {
        if ((page.startTime === null) || (page.endTime === null)) {
          // We used to construct these with just the date, which doesn't
          // convert to GMT, so the timeless events were someodd hours out
          // of sync with the events that had explicit times
          var start = new Date(page.startDate + ' ' + ((page.startTime === null) ? '00:00:00' : page.startTime));
          var end = new Date(page.endDate + ' ' + ((page.endTime === null) ? '00:00:00' : page.endTime));
          if ((page.start.getTime() !== start.getTime()) || (page.end.getTime() !== end.getTime())) {
            if (!used) {
              console.log('Fixing timeless events');
            }
            used = true;
            return self.pages.update({ _id: page._id }, { $set: { start: start, end: end } }, { safe: true }, callback);
          } else {
            return callback(null);
          }
        } else {
          return callback(null);
        }
      }, callback);
    });
  
    // Moved page rank of trash and search well beyond any reasonable
    // number of legit kids of the home page
    self.addMigration('moveTrash', function moveTrash(callback) {
      return self.pages.findOne({ type: 'trash' }, function(err, page) {
        if (!page) {
          return callback(null);
        }
        if (page.rank !== 1000999) {
          page.rank = 1000999;
          return self.pages.update({ _id: page._id }, page, callback);
        }
        return callback(null);
      });
    });
  
    self.addMigration('moveSearch', function moveSearch(callback) {
      return self.pages.findOne({ type: 'search' }, function(err, page) {
        if (!page) {
          return callback(null);
        }
        if (page.path !== 'home/search') {
          // This is some strange search page we don't know about and
          // probably shouldn't tamper with
          return callback(null);
        }
        if (page.rank !== 1000998) {
          page.rank = 1000998;
          return self.pages.update({ _id: page._id }, page, callback);
        }
        return callback(null);
      });
    });
  
    // This migration was argv dependent which was a bad idea.
    // There should be no projects left which need it
  
    // self.addMigration('fixButtons', function fixButtons(callback) {
    //   var count = 0;
    //   // There was briefly a bug in our re-normalizer where the hyperlink and
    //   // hyperlinkTitle properties were concerned. We can fix this, but
    //   // we can't detect whether the fix is necessary, and we don't want
    //   // to annoy people who have gone on with their lives and deliberately
    //   // removed hyperlinks. So we do this only if --fix-buttons is on the
    //   // command line
  
    //   if (!argv['fix-buttons']) {
    //     return callback(null);
    //   }
    //   return self.forEachItem(function(page, name, area, n, item, callback) {
    //     self.slideshowTypes = self.slideshowTypes || [ 'slideshow', 'marquee', 'files', 'buttons' ];
    //     if (!_.contains(self.slideshowTypes, item.type)) {
    //       return callback(null);
    //     }
    //     var ids = [];
    //     var extras = {};
    //     if (!item.legacyItems) {
    //       // This was created after the migration we're fixing so it's OK
    //       return callback(null);
    //     }
    //     count++;
    //     if (count === 1) {
    //       console.log('Fixing buttons damaged by buggy normalizer');
    //     }
    //     var interesting = 0;
    //     async.each(item.legacyItems, function(file, callback) {
    //       ids.push(file._id);
    //       var extra = {};
    //       extra.hyperlink = file.hyperlink;
    //       extra.hyperlinkTitle = file.hyperlinkTitle;
    //       if (extra.hyperlink || extra.hyperlinkTitle) {
    //         extras[file._id] = extra;
    //         interesting++;
    //       }
    //       return callback(null);
    //     }, function(err) {
    //       if (err) {
    //         return callback(err);
    //       }
    //       item.extras = extras;
    //       if (!interesting) {
    //         return callback(null);
    //       }
    //       var value = { $set: {} };
    //       // ♥ dot notation
    //       value.$set[name + '.items.' + n + '.extras'] = item.extras;
    //       return self.pages.update({ _id: page._id }, value, callback);
    //     });
    //   }, callback);
    // });
  
    // This migration was argv dependent which was a bad idea.
    // There should be no projects left which need it
  
    // self.addMigration('fixCrops', function fixCrops(callback) {
    //   var count = 0;
    //   // There was briefly a bug in our re-normalizer where the hyperlink and
    //   // hyperlinkTitle properties were concerned. We can fix this, but
    //   // we can't detect whether the fix is necessary, and we don't want
    //   // to annoy people who have gone on with their lives and deliberately
    //   // redone crops. So we do this only if --fix-crops is on the
    //   // command line
  
    //   if (!argv['fix-crops']) {
    //     return callback(null);
    //   }
    //   return self.forEachItem(function(page, name, area, n, item, callback) {
    //     self.slideshowTypes = self.slideshowTypes || [ 'slideshow', 'marquee', 'files', 'buttons' ];
    //     if (!_.contains(self.slideshowTypes, item.type)) {
    //       return callback(null);
    //     }
    //     var ids = [];
    //     var extras = {};
    //     if (!item.legacyItems) {
    //       // This was created after the migration we're fixing so it's OK
    //       return callback(null);
    //     }
    //     count++;
    //     if (count === 1) {
    //       console.log('Fixing crops damaged by buggy normalizer');
    //     }
    //     var interesting = 0;
    //     async.each(item.legacyItems, function(file, callback) {
    //       var value;
    //       if (file.crop) {
    //         var extra = item.extras[file._id];
    //         if (!extra) {
    //           extra = {};
    //         }
    //         if (!extra.crop) {
    //           extra.crop = file.crop;
    //           value = { $set: {} };
    //           value.$set[name + '.items.' + n + '.extras.' + file._id] = extra;
    //           return self.pages.update({ _id: page._id }, value, callback);
    //         }
    //       }
    //       return callback(null);
    //     }, callback);
    //   }, callback);
    // });
  
    self.addMigration('normalizeFiles', function normalizeFiles(callback) {
      var count = 0;
      // We used to store denormalized copies of file objects in slideshow
      // widgets. This made it difficult to tell if a file was in the trash.
      // At some point we might bring it back but only if we have a scheme
      // in place to keep backreferences so the denormalized copies can be
      // efficiently found and updated.
      //
      // Migrate the truly slideshow-specific parts of that data to
      // .ids and .extras, and copy any titles and descriptions and credits
      // found in .items to the original file object (because they have
      // been manually edited and should therefore be better than what is in
      // the global object).
      //
      // This means two placements can't have different titles, but that
      // feature was little used and only lead to upset when users couldn't
      // change the title globally for an image.
      return self.forEachItem(function(page, name, area, n, item, callback) {
        self.slideshowTypes = self.slideshowTypes || [ 'slideshow', 'marquee', 'files', 'buttons' ];
        if (!_.contains(self.slideshowTypes, item.type)) {
          return callback(null);
        }
        if (item.ids) {
          // Already migrated
          return callback(null);
        }
        var ids = [];
        var extras = {};
        count++;
        if (count === 1) {
          console.log('Normalizing file references in slideshows etc.');
        }
        async.each(item.items, function(file, callback) {
          ids.push(file._id);
          var extra = {};
          item.showTitles = !!(item.showTitles || (file.title));
          item.showCredits = !!(item.showCredits || (file.credit));
          item.showDescriptions = !!(item.showDescriptions || (file.description));
          extra.hyperlink = file.hyperlink;
          extra.hyperlinkTitle = file.hyperlinkTitle;
          extra.crop = file.crop;
          extras[file._id] = extra;
          if (!(file.title || file.credit || file.description)) {
            return callback(null);
          }
          // Merge the metadata found in this placement back to
          // the global file object
          return self.files.findOne({ _id: file._id }, function(err, realFile) {
            if (err) {
              return callback(err);
            }
            if (!realFile) {
              return callback(null);
            }
            if ((file.title === realFile.title) && (file.description === realFile.description) && (file.credit === realFile.credit)) {
              // We have values but they are not more exciting than what's
              // already in the file object
              return callback(null);
            }
            var value = { $set: {} };
            if (file.title) {
              value.$set.title = file.title;
            }
            if (file.description) {
              value.$set.description = file.description;
            }
            if (file.credit) {
              value.$set.credit = file.credit;
            }
            return self.files.update({ _id: file._id }, value, callback);
          });
        }, function(err) {
          if (err) {
            return callback(err);
          }
          item.ids = ids;
          item.extras = extras;
          // Just in case we didn't get this migration quite so right
          item.legacyItems = item.items;
          // Removed so we don't keep attempting this migration and
          // smooshing newer data
          delete item.items;
          var value = { $set: {} };
          // ♥ dot notation
          value.$set[name + '.items.' + n] = item;
          return self.pages.update({ _id: page._id }, value, callback);
        });
      }, callback);
    });
  
    self.addMigration('migrateTypeSettings', function migrateTypeSettings(callback) {
      return self.forEachPage({ typeSettings: { $exists: 1 } }, function(page, callback) {
        page.preMigrationTypeSettings = page.typeSettings;
        // Avoid conflict with the tags of the page itself
        if (_.has(page.typeSettings, 'tags')) {
          page.typeSettings.withTags = page.typeSettings.tags;
          delete page.typeSettings.tags;
        }
        extend(true, page, page.typeSettings);
        delete page.typeSettings;
        return self.pages.update({ _id: page._id }, page, callback);
      }, callback);
    });
  
    // function unmigrateAreas(callback) {
    //   return self.forEachPage({ preMigrationAreas: { $exists: 1 } }, function(page, callback) {
    //     page.areas = page.preMigrationAreas;
    //     console.log('unmigrating areas for ' + page.slug);
    //     return self.pages.update({ _id: page._id }, page, callback);
    //   }, callback);
    // }
  
    self.addMigration('migrateAreas', function migrateAreas(callback) {
      return self.forEachPage({ areas: { $exists: 1 } }, function(page, callback) {
        page.preMigrationAreas = page.areas;
        _.extend(page, page.areas);
        _.each(page.areas, function(val, key) {
          page[key].type = 'area';
        });
        delete page.areas;
        console.log('migrating areas for ' + page.slug);
        return self.pages.update({ _id: page._id }, page, callback);
      }, callback);
    });
  
    self.addMigration('addPermissionsProperty', function addPermissionsProperty(callback) {
      var needed = false;
      var silos = [
        {
          name: 'viewPersonIds',
          privilege: 'view'
        },
        {
          name: 'viewGroupIds',
          privilege: 'view'
        },
        {
          name: 'editPersonIds',
          privilege: 'edit'
        },
        {
          name: 'editGroupIds',
          privilege: 'group'
        }
      ];
  
      var or = [];
  
      var legacyPermissions = {};
  
      _.each(silos, function(silo) {
        var clause = {};
        clause[silo.name] = { $exists: 1 };
        or.push(clause);
      });
      return self.forEachPage({ $or: or }, function(page, callback) {
        var unset = {};
        if (!needed) {
          needed = true;
          console.log('migrating pagePermissions information to new pagePermissions property');
        }
  
        var pagePermissions = [];
        _.each(silos, function(silo) {
          legacyPermissions[silo.name] = page[silo.name];
          unset[silo.name] = 1;
          _.each(page[silo.name], function(id) {
            pagePermissions.push(silo.privilege + '-' + id);
          });
        });
        return self.pages.update({ _id: page._id }, { $set: { legacyPermissions: legacyPermissions, pagePermissions: pagePermissions }, $unset: unset }, callback);
      }, callback);
    });
  
    self.addMigration('fixStringifiedAreas', function(callback) {
      // Somehow we managed to get some areas whose "items" property is
      // an array of characters which, if joined, are a JSON
      // representation of what we should have had in "items"
      self.forEachArea(function(page, areaName, area, callback) {
        var used = false;
        if (area.items && area.items[0] === '[') {
          area.items = JSON.parse(area.items.join(''));
          var set = {};
          set[areaName + '.items'] = area.items;
          used = true;
          console.log('Fixing stringified areas...');
          return self.pages.update({ _id: page._id }, { $set: set }, callback);
        }
        return setImmediate(callback);
      }, callback);
    });
  
    // Do this again, because we mucked it up the first time by
    // not making the words unique
    self.addMigration('addHighSearchWordsUniquely', function addHighSearchWords(callback) {
      var needed = false;
      return self.forEachPage({ highSearchText: { $exists: 1 }, highSearchWords: { $exists: 0 } }, function(page, callback) {
        if (!needed) {
          needed = true;
          console.log('Adding highSearchWords index for fast autocomplete');
        }
        page.highSearchWords = _.uniq(page.highSearchText.split(/ /));
        return self.pages.update({ _id: page._id }, { $set: { highSearchWords: page.highSearchWords } }, callback);
      }, callback);
    });
  
    // Do this again, because we mucked it up the first time by
    // not making the words unique
    self.addMigration('pruneTemporaryProperties', function addHighSearchWords(callback) {
      var needed = false;
      return self.forEachPage({}, function(page, callback) {
        if (!needed) {
          needed = true;
          console.log('Pruning temporary properties of legacy pages...');
        }
        self.pruneTemporaryProperties(page);
        return self.pages.update({ _id: page._id }, page, callback);
      }, callback);
    });
  
    // multi: true was missing from the logic for making sure
    // descendants of a page in the trash are also marked as trash
    self.addMigration('recursiveTrash', function addHighSearchWords(callback) {
      return self.pages.findOne({ path: /^home\/trash\//, trash: { $exists: 0 } }, function(err, badTrash) {
        if (err) {
          return callback(err);
        }
        if (!badTrash) {
          return callback(null);
        }
        console.log('Marking all descendants of trashcan as trash');
        return self.pages.update({ path: /^home\/trash\// }, { $set: { trash: true } }, { multi: true }, callback);
      });
    });
  
    self.addMigration('videoType', function addVideoType(callback) {
      var needed = false;
      return self.forEachDocumentInCollection(self.videos, { type: { $exists: 0 } }, function(video, callback) {
        if (!needed) {
          needed = true;
          console.log('Adding type property to videos');
        }
        return self.videos.update({ _id: video._id }, { $set: { type: 'video' } }, callback);
      }, callback);
    });
  
    self.addMigration('removeVideoSearchTextIndex', function removeVideoSearchTextIndex(callback) {
      // This index was a dumb idea. It can't be used
      // (it would have to be a $text index to work), and
      // it imposes a hard cap on the length of the searchText,
      // crashing FM import
      return self.videos.dropIndex({ searchText: 1 }, function(err) {
        // Unfortunately you can't reliably distinguish due to
        // the lack of an error code, but this usually means the
        // index was already removed. That can happen because
        // A2 does not guarantee migrations won't run again.
        // So just allow it. -Tom
        return callback(null);
      });
    });
  
    self.addMigration('addSearchBoostToTextIndex', function addSearchBoostToTextIndex(callback) {
      // This first call will fail if the indexable properties
      // have changed
      return self.ensureTextIndex(function(err) {
        if (!err) {
          return callback(null);
        }
        console.log('Dropping and recreating text index to account for new searchable fields...');
        var info;
        return async.series({
          info: function(callback) {
            return self.pages.indexInformation(function(err, _info) {
              if (err) {
                return callback(err);
              }
              info = _info;
              return callback(null);
            });
          },
          drop: function(callback) {
            var key;
            _.each(info, function(val, _key) {
              if (_.some(val, function(field) {
                return field[1] === 'text';
              })) {
                key = _key;
                return false;
              }
            });
            if (!key) {
              console.error('Unable to ensure text index, but there is no existing one. Stumped.');
              return callback('notfound');
            }
            console.log('Dropping an obsolete text index...');
            return self.pages.dropIndex(key, callback);
          },
          retry: function(callback) {
            console.log('Creating a new text index...');
            return self.ensureTextIndex(callback);
          }
        }, callback);
      });
    });
  
    self.addMigration('addSortName', function sortName(callback) {
      var used = false;
      return self.forEachPage({ type: 'person', sortFirstName: { $exists: 0 } }, function(page, callback) {
        if (!used) {
          used = true;
          console.log('Adding case insensitive denormalization of first and last name');
        }
        return self.pages.update({
          _id: page._id
        }, {
          $set: {
            sortFirstName: self.sortify(page.firstName) || null,
            sortLastName: self.sortify(page.lastName) || null
          }
        }, callback);
      }, callback);
    }, { safe: true });
  };
  