Каскадное удаление связанных данных

Каскадное удаление в Dexie.js строится вокруг идеи поддержания согласованности данных между связанными таблицами при удалении родительских записей. В отличие от реляционных СУБД, IndexedDB не предоставляет встроенных внешних ключей и автоматических каскадных операций, поэтому вся логика поддержания целостности реализуется на уровне приложения.

В приложениях, использующих Dexie.js, данные часто структурируются по принципу связанных сущностей. Типичный пример — пользователи и их заказы:

  • users — таблица пользователей
  • orders — таблица заказов, где каждый заказ содержит userId

Без каскадного удаления возникает проблема «осиротевших записей»: при удалении пользователя его заказы остаются в базе, но теряют смысл, поскольку ссылка на пользователя больше не существует.

Такая ситуация приводит к:

  • неконсистентности данных
  • ошибкам в бизнес-логике
  • усложнению запросов (необходимость фильтрации мусора)
  • увеличению объёма хранилища

Dexie.js не навязывает стратегию решения, но предоставляет инструменты для построения каскадного поведения вручную.

Базовая схема данных

Типичная схема с отношением «один-ко-многим»:

const db = new Dexie("shop");

db.version(1).stores({
  users: "++id, name",
  orders: "++id, userId, total"
});

Здесь orders.userId выступает логической ссылкой на users.id. Dexie не контролирует эту связь автоматически, поэтому каскадное удаление реализуется вручную.

Подход 1: каскадное удаление через транзакции

Самый прямолинейный способ — явное удаление связанных записей внутри транзакции.

db.transaction("rw", db.users, db.orders, async () => {
  const userId = 1;

  await db.orders.where("userId").equals(userId).delete();
  await db.users.delete(userId);
});

Ключевые особенности подхода:

  • операция атомарна: либо удаляются и пользователь, и заказы, либо ничего
  • используется режим rw (readwrite) для обеих таблиц
  • порядок удаления важен: сначала дочерние записи, затем родитель

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

Подход 2: автоматизация через hooks (перехват событий)

Dexie предоставляет механизм hooks, позволяющий перехватывать операции удаления и расширять их поведение.

db.users.hook("deleting", async (primKey, obj, trans) => {
  await db.orders
    .where("userId")
    .equals(primKey)
    .delete();
});

Здесь логика каскада переносится на уровень модели:

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

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

Особенности выполнения hooks

  • deleting срабатывает до удаления записи
  • доступен первичный ключ и объект записи
  • можно выполнять асинхронные операции
  • транзакция блокирует другие изменения до завершения hook

Подход 3: каскад через централизованный сервис доступа к данным

В более сложных архитектурах каскадное удаление выносится в отдельный слой:

class UserRepository {
  async deleteUser(userId) {
    return db.transaction("rw", db.users, db.orders, async () => {
      await db.orders.where("userId").equals(userId).delete();
      await db.users.delete(userId);
    });
  }
}

Преимущества:

  • единая точка управления бизнес-логикой
  • проще тестировать поведение
  • исключается дублирование логики каскада

Недостаток — необходимость строгого соблюдения архитектурных правил всеми частями приложения.

Глубокие каскады (цепочки зависимостей)

Реальные приложения часто имеют несколько уровней связей:

  • users
  • orders
  • orderItems
  • shipments

В этом случае каскад становится цепочкой удалений:

db.users.hook("deleting", async (userId, user, trans) => {
  const orders = await db.orders.where("userId").equals(userId).toArray();

  for (const order of orders) {
    await db.orderItems.where("orderId").equals(order.id).delete();
    await db.shipments.where("orderId").equals(order.id).delete();
  }

  await db.orders.where("userId").equals(userId).delete();
});

Здесь важно соблюдать порядок:

  1. сначала удаляются «самые глубокие» зависимости
  2. затем промежуточные сущности
  3. в конце — родительская запись

Нарушение порядка приводит к необходимости многократных проходов по данным или временным несоответствиям.

Оптимизация каскадного удаления

При больших объёмах данных каскадные операции могут становиться узким местом. Dexie позволяет оптимизировать их несколькими способами.

Групповые удаления

Вместо удаления по одному ключу предпочтительно использовать bulk-операции:

const orderIds = await db.orders
  .where("userId")
  .equals(userId)
  .primaryKeys();

await db.orderItems.where("orderId").anyOf(orderIds).delete();
await db.orders.where("userId").equals(userId).delete();

Минимизация количества запросов

Каждый вызов where() — это отдельная операция IndexedDB. Поэтому важно:

  • извлекать ключи заранее
  • объединять условия через anyOf
  • избегать вложенных циклов с запросами

Каскадное удаление и индексы

Эффективность каскада напрямую зависит от наличия индексов:

db.version(1).stores({
  users: "++id, name",
  orders: "++id, userId, total",
  orderItems: "++id, orderId"
});

Без индекса userId каждая операция каскада превращается в полное сканирование таблицы orders, что критично ухудшает производительность.

Индексы позволяют:

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

Проблема рекурсивных каскадов

В сложных схемах возможно зацикливание логики удаления. Например:

  • users → orders → logs → users (через аудит)

Без контроля каскад может привести к бесконечным вызовам hooks.

Для предотвращения используют:

Флаг контекста транзакции

db.users.hook("deleting", async (id, obj, trans) => {
  if (trans.cascadeRunning) return;

  trans.cascadeRunning = true;

  await db.orders.where("userId").equals(id).delete();
});

Разделение уровней каскада

  • уровень доменных сущностей (users, orders)
  • уровень технических данных (logs, cache)

Технические таблицы часто исключаются из каскада или очищаются отдельно.

Каскадное удаление и массовые операции

При удалении больших наборов данных (например, удаление всех пользователей региона) каскад должен быть оптимизирован под batch-обработку:

await db.transaction("rw", db.users, db.orders, async () => {
  const userIds = await db.users
    .where("region")
    .equals("EU")
    .primaryKeys();

  await db.orders.where("userId").anyOf(userIds).delete();
  await db.users.where("region").equals("EU").delete();
});

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

Ошибки проектирования каскадов

На практике часто встречаются следующие проблемы:

Отсутствие централизованной логики

Удаление происходит в разных местах приложения без единых правил, что приводит к частично очищенным данным.

Использование циклов вместо bulk delete

// антипаттерн
for (const order of orders) {
  await db.orders.delete(order.id);
}

Игнорирование транзакций

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

Слишком глубокие цепочки зависимостей

Чем длиннее цепочка каскада, тем выше риск ошибок и сложнее сопровождение.

Комбинированные стратегии

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

  • hooks для базового каскада
  • транзакции для критичных операций
  • сервисный слой для бизнес-логики
  • bulk-операции для массовых удалений

Такая архитектура позволяет балансировать между автоматизацией и контролем.

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

Если внутри транзакции возникает ошибка:

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

Это делает каскадное удаление безопасным с точки зрения целостности, но требует аккуратной обработки исключений в hooks.

Итоговая логика проектирования каскадов

При проектировании каскадного удаления в Dexie.js ключевыми факторами становятся:

  • явное определение связей между таблицами
  • выбор стратегии (hooks, транзакции, сервисный слой)
  • оптимизация через индексы и bulk-операции
  • контроль глубины зависимостей
  • строгое соблюдение атомарности операций