Повторное использование транзакции между вызовами

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


Транзакция в Dexie создаётся как ограниченный по времени контекст. Она активна только внутри текущего микротаска-цепочки, и любые асинхронные разрывы, которые выходят за рамки оригинального Promise, приводят к её деактивации.

Типичная проблема проявляется при попытке косвенного использования:

await db.transaction('rw', db.users, async () => {
    await createUser(); // функция не знает о транзакции
});

Если createUser() выполняет операции без явного привязывания к транзакции, Dexie может рассматривать их как внешние вызовы, создавая новую транзакцию или вызывая TransactionInactiveError.


Явная передача транзакции как основа повторного использования

Наиболее устойчивый способ повторного использования транзакции — явная передача объекта транзакции в функции нижнего уровня.

db.transaction('rw', db.users, db.orders, async (tx) => {
    await createUser(tx, { name: 'Alice' });
    await createOrder(tx, { userId: 1, total: 100 });
});

Функции становятся чистыми операциями над транзакцией:

async function createUser(tx, data) {
    return tx.users.add(data);
}

async function createOrder(tx, data) {
    return tx.orders.add(data);
}

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


Разделение слоёв: сервисы и репозитории с транзакцией

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

async function serviceCreateUser(tx, input) {
    const id = await tx.users.add(input);
    await auditLog(tx, { action: 'create_user', id });
    return id;
}

async function auditLog(tx, entry) {
    return tx.logs.add(entry);
}

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


Повторное использование через db.transaction как композиционный корень

Dexie допускает создание транзакции как композиционного корня, внутри которого вызываются переиспользуемые функции:

await db.transaction('rw', db.users, db.logs, async (tx) => {
    await serviceCreateUser(tx, { name: 'Bob' });
});

Это формирует строгую иерархию: транзакция создаётся один раз, а все операции наследуют её контекст через явную передачу.


Использование Dexie.currentTransaction как неявного контекста

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

import { Dexie } fr om 'dexie';

async function createUser(data) {
    const tx = Dexie.currentTransaction;
    if (!tx) throw new Error('No active transaction');
    return tx.table('users').add(data);
}

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


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

При использовании Dexie.currentTransaction возникает ряд проблем:

  • функция становится зависимой от внешнего контекста;
  • невозможно безопасно вызвать её вне транзакции;
  • тестирование требует специальной подготовки окружения;
  • вложенные вызовы усложняют контроль границ транзакции.

В результате повторное использование ухудшается, несмотря на кажущуюся простоту.


Композиция транзакций через передачу контекста

Для сложных сценариев часто применяется композиционный стиль, при котором транзакция передаётся через цепочку функций:

async function createUserFlow(tx, user) {
    const id = await createUser(tx, user);
    await sendWelcomeEvent(tx, id);
    return id;
}

async function sendWelcomeEvent(tx, userId) {
    await tx.events.add({
        type: 'welcome',
        userId
    });
}

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


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

Иногда требуется разделить бизнес-логику на несколько независимых функций, но сохранить одну транзакцию:

await db.transaction('rw', db.users, db.orders, async (tx) => {
    const userId = await createUser(tx, { name: 'Eve' });
    const orderId = await createOrder(tx, { userId, total: 50 });

    await updateStats(tx, userId);
    await notify(tx, orderId);
});

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


Паттерн «транзакционный контекст как параметр»

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

  • она не создаётся внутри бизнес-функций;
  • она не скрыта в глобальном состоянии;
  • она передаётся явно между слоями.

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

function buildRepository(tx) {
    return {
        addUser: (data) => tx.users.add(data),
        addOrder: (data) => tx.orders.add(data),
    };
}

Рекурсия и вложенные вызовы в рамках одной транзакции

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

async function deleteUserCascade(tx, userId) {
    const orders = await tx.orders.wh ere('userId').equals(userId).toArray();

    for (const order of orders) {
        await tx.items.where('orderId').equals(order.id).delete();
    }

    await tx.orders.where('userId').equals(userId).delete();
    await tx.users.delete(userId);
}

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


Переиспользование транзакции через фабрики операций

Распространённый приём — создание фабрики, которая «захватывает» транзакцию:

function createUserOps(tx) {
    return {
        async createUser(data) {
            return tx.users.add(data);
        },
        async deleteUser(id) {
            return tx.users.delete(id);
        }
    };
}

Использование:

await db.transaction('rw', db.users, async (tx) => {
    const ops = createUserOps(tx);
    const id = await ops.createUser({ name: 'Max' });
    await ops.deleteUser(id);
});

Такой подход позволяет многократно использовать одну транзакцию через связанный API.


Ошибки повторного использования и типичные причины

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

  • вызов IndexedDB-операции вне переданного tx;
  • использование db.table вместо tx.table внутри транзакции;
  • попытка сохранить транзакцию и использовать её позже;
  • разрыв цепочки await, приводящий к завершению контекста.

Стратегия проектирования слоёв с учётом повторного использования

Корректная модель строится вокруг следующих принципов:

  • транзакция создаётся на верхнем уровне;
  • все нижележащие функции принимают tx;
  • доступ к db внутри бизнес-логики минимизируется;
  • операции группируются по смысловым единицам внутри одной транзакции.

Переиспользование транзакции в библиотечных слоях

В библиотеках поверх Dexie часто вводится абстракция:

export async function withTransaction(db, tables, fn) {
    return db.transaction('rw', tables, async (tx) => {
        return fn(tx);
    });
}

Использование:

await withTransaction(db, [db.users, db.logs], async (tx) => {
    await serviceCreateUser(tx, data);
});

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


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

В Dexie транзакция становится переносимым контекстом, который:

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

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