Обработка ошибок и откат транзакции

Dexie.js строится поверх IndexedDB и наследует его транзакционную модель, где операции выполняются атомарно внутри транзакций. Любая ошибка внутри транзакции приводит к её автоматическому откату, однако поведение зависит от того, как именно ошибка была вызвана: выброс исключения, отклонение Promise или явный вызов abort().

В основе обработки ошибок лежит принцип: транзакция считается успешной только если все операции внутри неё завершились без исключений и отклонений. Любая ошибка переводит транзакцию в состояние abort, после чего IndexedDB гарантирует отмену всех изменений, сделанных в рамках этого блока.


Модель ошибок в Dexie.js

Dexie различает несколько типов ошибок, влияющих на выполнение транзакции:

  • синхронные исключения (throw new Error)
  • асинхронные ошибки через Promise rejection
  • ошибки IndexedDB (ConstraintError, QuotaExceededError и др.)
  • явный вызов transaction.abort()

Каждый из этих случаев приводит к разному моменту остановки выполнения, но итоговый результат одинаков: транзакция помечается как отменённая и изменения откатываются.


Поведение транзакции при исключениях

Любое исключение, возникшее внутри функции транзакции, автоматически приводит к её прерыванию:

db.transaction('rw', db.users, async () => {
  await db.users.add({ id: 1, name: 'Alex' });

  throw new Error('Ошибка бизнес-логики');

  await db.users.add({ id: 2, name: 'Bob' });
});

После выброса ошибки:

  • выполнение транзакции немедленно прекращается
  • все несохранённые изменения откатываются
  • Dexie возвращает Promise в rejected состояние

Особенность IndexedDB заключается в том, что откат происходит не постфактум, а на уровне движка базы данных, что обеспечивает целостность данных.


Откат при Promise rejection

Асинхронные операции внутри транзакции также управляются через Promise. Если любой Promise в цепочке отклоняется, транзакция считается проваленной:

db.transaction('rw', db.orders, async () => {
  await db.orders.add({ id: 1 });

  await Promise.reject(new Error('Сбой операции'));

  await db.orders.add({ id: 2 });
});

Dexie автоматически перехватывает rejection и переводит транзакцию в aborted state.

Важно учитывать, что даже “пойманная” ошибка может привести к откату, если она была выброшена до обработки:

db.transaction('rw', db.orders, async () => {
  try {
    await Promise.reject(new Error('Ошибка'));
  } catch (e) {
    // обработка не предотвращает откат транзакции
  }
});

В данном случае транзакция всё равно считается неуспешной, поскольку rejection уже произошёл внутри контекста транзакции.


Явное прерывание через abort()

Dexie предоставляет механизм ручного прерывания транзакции через transaction.abort().

db.transaction('rw', db.users, async (tx) => {
  const user = await db.users.get(1);

  if (!user) {
    tx.abort();
    return;
  }

  await db.users.update(1, { active: true });
});

После вызова abort():

  • дальнейшие операции внутри транзакции игнорируются
  • Promise транзакции отклоняется
  • IndexedDB откатывает все изменения

abort() особенно полезен в сценариях бизнес-валидации, когда ошибка не является исключением, но выполнение должно быть остановлено.


Поведение Dexie при ошибках IndexedDB

IndexedDB может генерировать системные ошибки, которые Dexie не преобразует в бизнес-исключения:

  • нарушение уникальности ключа (ConstraintError)
  • превышение квоты хранилища
  • операции с закрытой транзакцией
  • конфликт версий schema

Пример:

db.transaction('rw', db.users, async () => {
  await db.users.add({ id: 1 });
  await db.users.add({ id: 1 }); // ConstraintError
});

Такие ошибки автоматически приводят к rollback без необходимости ручного вмешательства.


Обработка ошибок через try/catch

try/catch внутри транзакции позволяет перехватывать ошибки, но не всегда предотвращает откат:

db.transaction('rw', db.users, async () => {
  try {
    await db.users.add({ id: 1 });
    throw new Error('fail');
  } catch (e) {
    console.log('перехвачено');
  }
});

Несмотря на перехват, транзакция будет считаться неуспешной, если ошибка не “поглощена” корректным образом до завершения Promise цепочки.

Ключевой момент: Dexie ориентируется не на факт обработки ошибки, а на состояние Promise транзакции.


Гарантии атомарности и отката

Dexie обеспечивает атомарность операций:

  • либо выполняются все операции транзакции
  • либо ни одна не фиксируется

Это означает, что при любом сбое состояние базы остаётся консистентным.

Особенно важно учитывать это при работе с несколькими таблицами:

db.transaction('rw', db.users, db.logs, async () => {
  await db.users.add({ id: 1 });
  await db.logs.add({ event: 'create user' });

  throw new Error('fail');
});

Обе операции будут отменены, даже если одна из них уже была выполнена.


Поведение вложенных транзакций

Dexie поддерживает вложенные транзакции, но они не создают независимых rollback-секций. Ошибка во внутреннем блоке приводит к откату всей внешней транзакции:

db.transaction('rw', db.users, async () => {
  await db.users.add({ id: 1 });

  await db.transaction('rw', db.users, async () => {
    throw new Error('inner fail');
  });
});

Результат:

  • внешняя транзакция также отменяется
  • состояние базы возвращается к исходному

События и хуки ошибок

Dexie позволяет подписываться на события транзакции:

db.transaction('rw', db.users, async (tx) => {
  tx.on('error', (err) => {
    console.log('ошибка транзакции:', err);
  });

  await db.users.add({ id: 1 });
  throw new Error('fail');
});

Доступные события:

  • error — ошибка до отката
  • complete — успешное завершение
  • abort — транзакция отменена

Эти события полезны для логирования и мониторинга состояния базы.


Повторное выполнение после ошибки

После отката транзакции Dexie не выполняет автоматический retry. Повторная попытка должна быть реализована вручную:

async function safeWrite() {
  try {
    await db.transaction('rw', db.users, async () => {
      await db.users.add({ id: 1 });
      throw new Error('fail');
    });
  } catch (e) {
    return safeWrite();
  }
}

При этом важно учитывать риск бесконечных циклов при постоянных ошибках.


Особенности Promise-цепочек и отката

Dexie использует внутреннюю интеграцию с async context транзакции. Это означает:

  • транзакция активна только внутри своего async scope
  • любые асинхронные операции вне контекста не участвуют в rollback
  • потеря контекста приводит к частичному выполнению вне транзакции
db.transaction('rw', db.users, async () => {
  setTimeout(async () => {
    await db.users.add({ id: 1 });
  }, 100);
});

В этом случае операция может выполниться вне транзакции и не будет откатана.


Стратегии устойчивой обработки ошибок

Поведение Dexie позволяет строить несколько стратегий:

  • fail-fast: мгновенный abort при первой ошибке
  • validation-before-write: предварительная проверка данных
  • компенсирующие операции: логирование и восстановление после reject
  • централизованный обработчик транзакций через tx.on('error')

Ключевым элементом остаётся понимание того, что транзакция — это неделимая единица выполнения, и любая ошибка внутри неё разрушает весь блок операций.