ConstraintError и дубликаты ключей

ConstraintError — одна из наиболее распространённых ошибок при работе с IndexedDB и Dexie.js. Она возникает в ситуациях, когда операция нарушает ограничения целостности данных, определённые схемой таблицы. На практике наиболее частой причиной становится попытка сохранить запись с уже существующим уникальным ключом или индексом.

Понимание механизма возникновения ConstraintError особенно важно при разработке приложений с синхронизацией данных, многопользовательскими сценариями, импортом больших объёмов информации и пакетными операциями записи.


Что такое ConstraintError

В основе Dexie.js лежит IndexedDB, которая поддерживает различные ограничения:

  • уникальность первичного ключа;
  • уникальность вторичных индексов;
  • ограничения составных индексов;
  • ограничения мультииндексов с флагом уникальности.

Когда операция записи нарушает одно из этих ограничений, IndexedDB генерирует исключение ConstraintError, которое Dexie преобразует в собственный объект ошибки.

Простейший пример:

db.version(1).stores({
    users: "id, email"
});

await db.users.add({
    id: 1,
    email: "user@example.com"
});

await db.users.add({
    id: 1,
    email: "another@example.com"
});

Вторая операция завершится ошибкой:

Dexie.ConstraintError

Причина очевидна: запись с ключом id = 1 уже существует.


Дубликаты первичных ключей

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

Схема:

db.version(1).stores({
    products: "id, name"
});

Добавление объекта:

await db.products.add({
    id: 100,
    name: "Laptop"
});

Повторная вставка:

await db.products.add({
    id: 100,
    name: "Monitor"
});

Завершится ошибкой:

ConstraintError

Поскольку метод add() предполагает создание новой записи и запрещает перезапись существующей.


Почему add() генерирует ошибку

Метод add() работает по принципу:

Добавить запись только если ключ отсутствует.

Если запись существует, операция считается нарушением ограничения.

Поведение напоминает SQL-конструкцию:

INS ERT IN TO table (...)

без поддержки обновления существующих строк.


Разница между add() и put()

Одна из наиболее распространённых причин появления ConstraintError — неправильный выбор метода записи.

add()

await db.users.add({
    id: 1,
    name: "Alex"
});

Создаёт запись только при отсутствии ключа.

При наличии ключа:

ConstraintError

put()

await db.users.put({
    id: 1,
    name: "Alex"
});

Поведение:

  • если записи нет — создаётся новая;
  • если запись есть — выполняется обновление.

Поэтому:

await db.users.put({
    id: 1,
    name: "John"
});

не вызывает ошибок.


Автоинкрементные ключи и ConstraintError

Схема с автоинкрементом:

db.version(1).stores({
    posts: "++id, title"
});

Обычная вставка:

await db.posts.add({
    title: "Article"
});

Идентификатор будет создан автоматически.

Однако ошибка возможна при явном указании уже существующего значения:

await db.posts.add({
    id: 1,
    title: "Duplicate"
});

Если запись с таким идентификатором существует, будет сгенерирован ConstraintError.


Уникальные индексы

ConstraintError возникает не только для первичных ключей.

Dexie поддерживает уникальные вторичные индексы.

Пример:

db.version(1).stores({
    users: "++id, &email"
});

Символ & означает уникальность индекса.

Первая запись:

await db.users.add({
    email: "user@example.com"
});

Вторая запись:

await db.users.add({
    email: "user@example.com"
});

Приведёт к ошибке.

Хотя идентификаторы различаются, значение уникального индекса совпадает.


Практическое значение уникальных индексов

Уникальные индексы позволяют гарантировать отсутствие дубликатов:

"&username"
"&email"
"&phone"
"&passportNumber"

Например:

db.version(1).stores({
    accounts: `
        ++id,
        &email,
        &username
    `
});

Теперь невозможно создать два аккаунта с одинаковым email или логином.


Перехват ConstraintError

Наиболее распространённый способ обработки:

try {
    await db.users.add(user);
}
catch (error) {
    if (error instanceof Dexie.ConstraintError) {
        console.log("Дубликат ключа");
    }
}

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


Использование имени ошибки

Иногда проверка выполняется через свойство name.

try {
    await db.users.add(user);
}
catch (error) {
    if (error.name === "ConstraintError") {
        console.log("Обнаружен дубликат");
    }
}

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


Проверка существования записи до вставки

Иногда ошибка предотвращается предварительной проверкой.

Пример:

const existing = await db.users.get(1);

if (!existing) {
    await db.users.add({
        id: 1,
        name: "Alex"
    });
}

Подход уменьшает вероятность появления ConstraintError.

Однако следует учитывать проблему конкурентного доступа.


Проблема гонки данных

Следующий код выглядит безопасным:

const exists = await db.users.get(1);

if (!exists) {
    await db.users.add(user);
}

Но между проверкой и вставкой другая операция может создать запись с тем же ключом.

Сценарий:

  1. Процесс A выполняет get().
  2. Процесс B выполняет add().
  3. Процесс A выполняет add().

Результат:

ConstraintError

Поэтому предварительная проверка не заменяет полноценную обработку ошибок.


Использование транзакций

Транзакция уменьшает вероятность логических конфликтов.

await db.transaction(
    "rw",
    db.users,
    async () => {
        const exists = await db.users.get(1);

        if (!exists) {
            await db.users.add(user);
        }
    }
);

Тем не менее даже внутри транзакции следует быть готовым к ConstraintError.


Пакетные операции и дубликаты

Рассмотрим пример:

await db.users.bulkAdd([
    { id: 1, name: "A" },
    { id: 2, name: "B" },
    { id: 1, name: "C" }
]);

Одна из записей содержит повторяющийся ключ.

Результатом станет ошибка.


BulkError при массовой вставке

Для пакетных операций Dexie использует специальное исключение.

try {
    await db.users.bulkAdd(data);
}
catch (error) {
    if (error instanceof Dexie.BulkError) {
        console.log(error.failures);
    }
}

Внутри объекта ошибки можно получить список проблемных записей.


bulkPut как способ избежать конфликтов

Если требуется обновление существующих записей:

await db.users.bulkPut([
    { id: 1, name: "Alex" },
    { id: 2, name: "John" }
]);

Метод работает аналогично обычному put():

  • создаёт отсутствующие записи;
  • обновляет существующие.

Поэтому дубликаты первичного ключа не вызывают ConstraintError.


ConstraintError при составных индексах

Dexie поддерживает составные индексы.

Пример:

db.version(1).stores({
    employees: `
        ++id,
        &[department+employeeNumber]
    `
});

Комбинация:

{
    department: "IT",
    employeeNumber: 100
}

должна быть уникальной.

Попытка создать вторую запись с тем же сочетанием значений приведёт к ConstraintError.


ConstraintError при мультииндексах

Мультииндексы используются для массивов.

Пример:

db.version(1).stores({
    articles: `
        ++id,
        *tags
    `
});

Если такой индекс объявлен как уникальный:

"&*tags"

то каждая метка становится уникальным значением.

Попытка добавить запись с уже существующим тегом может вызвать ConstraintError.

Из-за сложности поведения подобные конструкции используются редко.


Обработка импорта данных

При импорте большого массива объектов дубликаты встречаются часто.

Опасный вариант:

await db.users.bulkAdd(importedUsers);

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

Более надёжный подход:

try {
    await db.users.bulkAdd(importedUsers);
}
catch (error) {
    if (error instanceof Dexie.BulkError) {
        console.log(
            `${error.failures.length} конфликтов`
        );
    }
}

Стратегии разрешения конфликтов

Игнорирование существующих записей

for (const user of users) {
    try {
        await db.users.add(user);
    }
    catch (error) {
        if (!(error instanceof Dexie.ConstraintError)) {
            throw error;
        }
    }
}

Дубликаты просто пропускаются.


Замена существующих данных

await db.users.put(user);

Существующая запись обновляется автоматически.


Слияние данных

const existing = await db.users.get(user.id);

if (existing) {
    await db.users.put({
        ...existing,
        ...user
    });
}
else {
    await db.users.add(user);
}

Подход часто используется при синхронизации.


Генерация нового ключа

Иногда конфликт решается созданием нового идентификатора.

await db.notes.add({
    id: crypto.randomUUID(),
    text: "Note"
});

Вероятность дублирования становится практически нулевой.


Диагностика ConstraintError

Полезно выводить полную информацию об ошибке.

try {
    await db.users.add(user);
}
catch (error) {
    console.error(error.name);
    console.error(error.message);
}

Типичный вывод:

ConstraintError
A mutation operation in the transaction failed...

Текст сообщения зависит от браузера и реализации IndexedDB.


Наиболее распространённые причины появления ConstraintError

Повторная вставка существующего первичного ключа

add()

вместо

put()

Нарушение уникального индекса

&email

при сохранении одинаковых адресов электронной почты.


Ошибки импорта

Несколько объектов содержат одинаковые идентификаторы.


Конфликты синхронизации

Локальные и удалённые данные используют одинаковые ключи.


Конкурирующие операции записи

Несколько вкладок или процессов пытаются создать запись одновременно.


Практические рекомендации

  • Использовать add() только тогда, когда существование записи считается ошибкой.
  • Использовать put() для сценариев обновления и синхронизации.
  • Всегда перехватывать ConstraintError.
  • Проверять уникальные индексы при проектировании схемы.
  • Для массовых операций обрабатывать BulkError.
  • Не полагаться исключительно на предварительные проверки через get().
  • При распределённых системах предусматривать стратегию разрешения конфликтов.
  • Использовать UUID или другие устойчивые механизмы генерации ключей при необходимости глобальной уникальности.

Понимание природы ConstraintError позволяет строить надёжные механизмы хранения данных, корректно обрабатывать конфликты записи и предотвращать появление повреждённых или дублирующихся записей в локальной базе данных IndexedDB.