Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Make IndexedDB upgrades atomic and recoverable

Why this matters IndexedDB schema changes aren’t just “make a new store” — they usually also move existing records into the new shape. If the browser aborts an upgrade after the destination is created but before every r

Why this matters

IndexedDB schema changes aren’t just “make a new store” — they usually also move existing records into the new shape. If the browser aborts an upgrade after the destination is created but before every record is copied, your app can be left with a database that’s neither the old nor the new schema.

What this pattern does

  • Keep all structural work (create target store, copy records, remove source) inside the onupgradeneeded/versionchange callback so the browser’s versionchange transaction owns the entire move.
  • Before copying records, write a durable migration marker that survives an abort. The marker is a recovery hint, not the migration itself.
  • On next open, inspect the marker and the presence of source/target stores and choose the smallest recovery action: rerun the move, clear a stale marker, or remove a redundant source.

Expected behavior

  • Successful upgrade: the versionchange transaction commits, the target store exists with all records, and the marker is removed.
  • Simulated abort during move: the transaction aborts, the source remains intact, the marker persists. On next open, the recovery logic detects the marker and either retries the move to completion or clears the marker if the target already exists.
  • Edge cases: if both source and target exist, the conservative policy treats the target as canonical, removes the source, and clears the marker.

Limitations and when not to use it

  • This pattern assumes migrations are deterministic and repeatable using only local data. If the migration touches external services or non-idempotent transforms, retries may produce incorrect results and you’ll need a stronger reconciliation approach.
  • The example models browser behavior but cannot prove every real-world failure path (OS-level crashes, device power loss, or browser-specific flush timing). It proves the application-side recovery contract, not absolute cross-browser durability.

What you get from the repository

  • A small, honest simulator that models versionchange transactions, a durable migration marker, and recovery checks.
  • Tests that assert the recovery policy (marker cleared after success, marker present after abort, recovery re-applies the migration).

Working example

// 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

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.