Skip to content

Транзакции

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

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

Если коротко:

  • синхронные вызовы юнитов работают в одной транзакции;
  • записи в сторы попадают в draft транзакции;
  • чтения сторов внутри транзакции видят draft;
  • сторы коммитятся один раз после внешнего синхронного вызова;
  • реакции на изменившиеся сторы запускаются после коммита;
  • await завершает текущую транзакцию;
  • lifecycle-сторы эффектов публикуются сразу.

Зачем нужны транзакции

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

ts
reaction({
  on: incremented,
  run() {
    count.value++;
    count.value++;
  },
});

Полезный результат здесь — count + 2, а не два отдельных рендера UI и две отдельные инвалидации computed. В Virentia обе записи обновляют draft транзакции, а count коммитится один раз с финальным значением.

Что начинает транзакцию

Транзакция начинается при запуске юнита:

ts
await allSettled(submitted, { scope: appScope });

То же правило работает для прямых вызовов юнитов внутри активного scope:

ts
scoped(appScope, () => {
  submitted();
});

Прямая запись в стор вне существующей транзакции создает маленькую неявную транзакцию:

ts
scoped(appScope, () => {
  count.value = 1;
});

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

Чтение draft

Внутри транзакции последующий код читает текущее draft-значение.

ts
reaction({
  on: incremented,
  run() {
    count.value++;
    console.log(count.value); // уже включает первый increment

    count.value++;
    console.log(count.value); // включает оба increment
  },
});

Снаружи транзакции наблюдатели видят только закоммиченные значения. Подписчики, derived stores, реакции и UI-bindings не видят промежуточное значение после первой записи.

Коммит и уведомления

После завершения внешней синхронной транзакции Virentia коммитит изменившиеся сторы. Каждый изменившийся стор применяет финальное значение и затем уведомляет подписчиков.

txt
unit starts
  read stores
  write drafts
  call nested units
commit changed stores
notify subscribers and derived graph
run reactions caused by committed stores

Если стор получает то же значение по Object.is, update пропускается.

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

Явные вложенные вызовы

Явные синхронные вызовы сохраняют обычный порядок JavaScript.

ts
reaction({
  on: featureTogglePressed,
  run() {
    featureEnabled();
    legacyModeDisabled();
  },
});

featureEnabled выполнится раньше legacyModeDisabled. Если обе ветки читают и пишут один стор, вторая ветка увидит draft-изменения первой.

Это сделано специально. Когда пользователь пишет вызовы в конкретном порядке, Virentia уважает этот порядок и не перекладывает работу в скрытые priority layers.

Соседние реакции

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

ts
reaction({
  on: submitted,
  run() {
    count.value = 1;
  },
});

reaction({
  on: submitted,
  run() {
    console.log(count.value);
  },
});

Порядок рантайма детерминированный, но бизнес-логика не должна зависеть от порядка соседних реакций. Если одна соседняя реакция пишет стор, а другая читает этот же стор в той же транзакции, результат зависит от порядка подписки.

WARNING

Считайте такой код order-dependent. Virentia не запрещает его, потому что иногда это допустимо для низкоуровневых сценариев, но бизнес-правила лучше не строить на порядке регистрации соседних реакций.

Лучше писать так:

ts
reaction({
  on: submitted,
  run() {
    count.value = 1;
    nextStep();
  },
});

Или реагировать на закоммиченное значение стора:

ts
reaction({
  on: count,
  run(value) {
    console.log(value);
  },
});

Несколько записей и конфликты

Несколько записей в явном коде валидны. Побеждает последняя явная запись.

ts
reaction({
  on: changed,
  run() {
    count.value = 1;
    count.value = 2;
  },
});

Закоммитится 2.

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

ts
reaction({ on: changed, run: () => { count.value = 1; } });
reaction({ on: changed, run: () => { count.value = 2; } });

Результат будет детерминированным, но важное правило такое: не завязывайте бизнес-решения на порядок соседних реакций. Вынесите решение в одну реакцию, вызывайте юниты явно или используйте отдельный примитив для append/merge-heavy данных, когда он появится.

WARNING

Если devtools или runtime diagnostics подсвечивают несколько sibling writes в один стор, воспринимайте это как повод пересмотреть модель. Это не обязательно ошибка выполнения, но почти всегда слабое место в причинности.

Эффекты и lifecycle-сторы

У эффектов есть lifecycle-сторы:

ts
searchFx.$pending;
searchFx.$inFlight;

Это состояние исполнения рантайма. Оно публикуется сразу при старте и завершении async-работы, даже если эффект запущен внутри транзакции.

ts
reaction({
  on: submitted,
  run() {
    formTouched.value = true;
    searchFx(query.value);
  },
});

searchFx.$pending станет true сразу. UI сможет показать loading state, не дожидаясь коммита несвязанных бизнес-записей.

Lifecycle-события вроде started, doneData, failData и settled при этом остаются обычными юнитами. Если реакции на эти события пишут бизнес-сторы, такие записи транзакционные.

Async-границы

await завершает текущую транзакцию. Ожидающие записи в сторы коммитятся до продолжения async-кода.

ts
scoped(appScope, async () => {
  saving.value = true;

  const user = await saveUserFx(form.value);

  profile.value = user;
  saving.value = false;
});

Это две транзакции:

txt
transaction 1:
  saving = true
commit

await saveUserFx

transaction 2:
  profile = user
  saving = false
commit

Draft не живет через await. Если держать mutable draft через async-работу, lifetime и правила конфликтов станут слишком неочевидными.

ts
const runInScope = scoped(appScope);

button.addEventListener("click", runInScope.wrap(async () => {
  saving.value = true;
  await saveFx();
  saving.value = false;
}));

scoped в таком коде нужен не для продления транзакции, а для сохранения scope через внешний callback или async-продолжение. Каждый синхронный участок все равно получает свою транзакцию.

Практические правила

  • Пишите прямой императивный код, когда порядок явный.
  • Давайте сторам коммититься один раз вместо ручного batching.
  • Реагируйте на закоммиченные значения сторов, если логика зависит от результата записи.
  • Не завязывайте бизнес-решения на порядок соседних реакций.
  • Используйте lifecycle-сторы эффектов для UI-состояния исполнения.
  • Оборачивайте async callbacks в scoped, если им нужен scope.
  • Считайте await границей транзакции.

Модель специально близка к JavaScript: синхронный код выполняется в написанном порядке, async-работа разделяет выполнение, а наблюдатели видят закоммиченное состояние вместо каждого промежуточного write.