Debug the retry decision before trusting the migration
The useful starting point is the failed path, not a successful rename. This article includes a Node-memory model of an IndexedDB-style migration. It lets you inspect the source store, destination store and migration marker after a deliberately interrupted move. It does not open a browser database or prove crash durability.
Create a fixture with several records in the source store. First run migrateMove with shouldAbortDuringMove enabled. Check that the source records are still available, the staged destination was not committed and the model retained its in-progress marker. Those observations explain why restarting the application and blindly copying records again is not a sufficient recovery policy.
Next call detectAndRecover with the same source and destination names. In this interrupted-state case, the helper attempts the move again. The successful path creates the destination, removes the source and clears the marker. Compare that result with the separate case where the destination already exists and the source is gone: there, the helper clears stale metadata rather than copying a second time.
Use the included tests to follow those state transitions, then read the implementation's other branches before adapting the policy. The treatment of both stores being present is an application assumption in this fixture, not a browser guarantee or a universal conflict-resolution rule.
For a real IndexedDB implementation, translate the contract into versionchange transaction handling and verify abort behavior in an actual browser. A marker in this in-memory model is not evidence that a marker written inside an aborted browser transaction survives that abort. Keep that distinction explicit when designing recovery.
Verified implementation used by the article
// Simulation of an IndexedDB-style versionchange migration that is
// atomic and recoverable. THIS IS A NODE-IN-MEMORY SIMULATOR, not
// real IndexedDB. It models the important behaviors (versionchange
// transaction, onabort/oncomplete, durable migration marker) so the
// article can demonstrate detection of partial upgrades and a retry
// path that either completes or rolls back the migration.
//
// Public API:
// - createFixture(stores): returns an in-memory DB object { stores, meta }
// - migrateMove(db, fromName, toName, { shouldAbortDuringMove })
// Performs a versionchange-style migration that moves all records
// from `fromName` to `toName` atomically. It writes a durable
// marker (meta._migration) to indicate progress; if aborted, the
// marker persists to simulate a partially-applied upgrade.
// - detectAndRecover(db, fromName, toName): looks for partial
// migration and either rolls back or completes the move so the DB
// ends up consistent.
//
// This simulator intentionally exposes the metadata needed by the
// recovery algorithm so tests can assert the exact recovery behavior.
export function createFixture(stores = {}) {
// stores: { name: { [key]: value } }
const clone = JSON.parse(JSON.stringify(stores));
return {
stores: clone,
// meta simulates on-disk migration markers that survive an abort.
meta: {}
};
}
// Internal helper to deep clone store contents
function cloneStore(store) {
return JSON.parse(JSON.stringify(store));
}
// Perform a migration that moves all records from an existing store
// `fromName` into a new store `toName` inside a single "versionchange"
// transaction. The transaction can be forced to abort mid-transfer by
// setting shouldAbortDuringMove to true. When the transaction starts
// we write a durable meta marker { migrating: { from,to } } before
// starting to move records to simulate on-disk progress visibility.
// If the transaction aborts, the stores must be left as if the
// transaction never happened (rolled back), but the durable marker
// may remain to indicate partial progress — this models a browser
// crash between steps. A real IndexedDB upgrade should ensure the
// onupgradeneeded handler leaves a detectable and recoverable state.
export function migrateMove(db, fromName, toName, options = {}) {
const { shouldAbortDuringMove = false } = options;
if (!db.stores[fromName]) {
throw new Error('source store not found: ' + fromName);
}
if (db.stores[toName]) {
throw new Error('target store already exists: ' + toName);
}
// Begin "versionchange transaction"
const tx = {
aborted: false,
onabort: null,
oncomplete: null
};
// Durable marker written at the start of upgrade, visible across
// crashes/aborts in this simulation.
db.meta._migration = { state: 'in-progress', from: fromName, to: toName };
// Create the new store but buffer its contents locally (not yet
// committed) to simulate transaction-local writes.
const stagedTo = {};
try {
const source = db.stores[fromName];
const keys = Object.keys(source);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
// copy value into staged target
stagedTo[k] = cloneStore(source[k]);
// Optionally abort mid-transfer to simulate a thrown exception
if (shouldAbortDuringMove && i === Math.floor(keys.length / 2)) {
throw new Error('simulated mid-migration crash');
}
}
// If we reach here, commit the staged writes atomically.
db.stores[toName] = stagedTo;
// Remove source store to model a move
delete db.stores[fromName];
// Clear durable marker and mark transaction complete
delete db.meta._migration;
tx.aborted = false;
if (tx.oncomplete) tx.oncomplete();
return { committed: true };
} catch (err) {
tx.aborted = true;
if (tx.onabort) tx.onabort(err);
// On abort, we must leave the logical stores as if transaction
// never happened — rollback staged changes. We intentionally
// keep db.meta._migration to simulate a partially applied
// on-disk marker that recovery must inspect.
// Note: we do NOT delete db.meta._migration here to simulate a
// crash that occurred after the marker was written but before
// the migration completed.
return { committed: false, error: err };
}
}
// Detect a partially-applied migration and recover it. Recovery policy:
// - If meta indicates an in-progress migration from->to and the target
// store exists, assume commit succeeded and remove the marker.
// - If meta indicates in-progress but target doesn't exist, it means
// the migration didn't finish. In that case, we rollback any remnants
// and retry the migration to complete it atomically.
// This function returns an object describing what it did.
export function detectAndRecover(db, fromName, toName, options = {}) {
const m = db.meta._migration;
if (!m || m.from !== fromName || m.to !== toName) {
return { recovered: false, reason: 'no-migration-marker' };
}
const targetExists = Boolean(db.stores[toName]);
const sourceExists = Boolean(db.stores[fromName]);
if (targetExists && !sourceExists) {
// Looks like commit succeeded but marker wasn't cleared.
delete db.meta._migration;
return { recovered: true, action: 'cleared-marker' };
}
if (!targetExists && sourceExists) {
// Partial: migration marker exists but nothing committed. Retry
// the migration but ensure it completes atomically.
const retry = migrateMove(db, fromName, toName, options);
if (retry.committed) return { recovered: true, action: 'reapplied' };
return { recovered: false, action: 'retry-failed', error: retry.error };
}
// Edge case: both exist (duplicate). Prefer target as canonical
// (assume it committed), remove source and marker.
if (targetExists && sourceExists) {
delete db.stores[fromName];
delete db.meta._migration;
return { recovered: true, action: 'removed-source-presumed-commit' };
}
return { recovered: false, reason: 'unknown-state' };
}
Read the full article: https://chriseugenerodriguez.com/blog/indexeddb-atomic-recoverable-migration