Внутреннее устройство
Эта страница объясняет, что происходит под публичным API Virentia. Для обычной разработки приложений это знать не обязательно. Раздел полезен, если вы пишете адаптеры, разбираете порядок выполнения или решаете, где должен жить новый примитив.
Юниты — это ноды графа
Публичное API говорит про сторы, события, эффекты и реакции. Внутри каждый из этих примитивов владеет нодами графа или создает их. Нода — маленький исполняемый шаг графа. Ноды связаны через next, поэтому один юнит может запустить другой, не зная, кто именно к нему подключен.
Когда внешняя граница запускает событие, Virentia не обходит весь граф рекурсивно. Вместо этого она кладет задачу в kernel queue. В задаче есть нода, payload, scope и контекст выполнения. После этого queue последовательно выполняет ноды.
Поэтому payload и scope идут вместе. Payload говорит следующей ноде, что произошло. Scope говорит сторам, где читать и писать значения.
Сторы — определения, scopes хранят значения
Стор — не само значение. Стор владеет стабильной identity и знает, как по ней читать или писать данные. Scope хранит фактические значения.
Когда код читает query.value, стор берет scope из текущего контекста выполнения и ищет в этом scope собственный id. Если значения еще нет, стор возвращает начальное значение.
Когда код пишет query.value = "docs", стор запускает свою ноду в текущем scope. Нода записывает новое значение в scope.values и уведомляет подписчиков, которые смотрят на тот же scope.
Благодаря этому код модели можно переиспользовать. Модель можно импортировать один раз, а каждый экземпляр приложения, запрос, тест или кешированный экран получит собственную карту значений.
Реакции — связи с поведением
Явная реакция прикрепляет свою ноду к исходному юниту.
Например, если реакция слушает queryChanged, Virentia добавляет ноду реакции в queryChanged.node.next. Когда событие запускается, kernel доходит до этой ноды с тем же payload и scope. Тело реакции может писать сторы, вызывать эффекты или запускать другую логику модели.
Автоматические реакции — основной режим для большинства правил модели. При создании они запускаются один раз и запоминают сторы, которые были прочитаны во время run. Эти ноды сторов становятся зависимостями. Когда одна из зависимостей меняется, реакция запускается снова и обновляет список зависимостей.
Явные реакции остаются альтернативой для мест, где важна конкретная причина запуска: событие, эффект или юнит жизненного цикла. В таком коде on делает источник и payload частью правила.
Эффекты — это цепочки нод
Эффект — не просто async-функция. Это небольшой граф вокруг асинхронной операции.
Стартовая нода увеличивает inFlight, обновляет pending и вызывает started. Нода выполнения делает await handler. Завершающая нода уменьшает inFlight, обновляет pending и вызывает юниты успешного или неуспешного завершения.
Этот жизненный цикл доступен как обычные юниты: done, doneData, fail, failData, settled, pending и inFlight. Код модели может реагировать на них так же, как на события.
Отмена привязана к каждому выполняющемуся вызову. Handler получает AbortSignal, а dispose у владельца может отменить вызовы эффекта, созданные внутри него.
Kernel queue
Kernel queue задает контролируемый порядок выполнения графа. Нода может вернуть значение, остановить текущую ветку, завершить ее ошибкой или поставить следующие ноды в очередь.
Каждая задача в очереди несет:
- ноду, которую нужно запустить;
- payload, с которым выполняется эта ветка;
- текущее значение, полученное от предыдущей ноды;
- scope;
- контекст выполнения;
- метаданные для интеграций.
Scope всегда часть задачи в очереди. Это важный момент: если юнит стартовал в scope, следующие ноды получают тот же scope, если низкоуровневая интеграция намеренно не меняет его.
Транзакции и drain context
Каждый run кладет работу в drain context. Drain хранит очередь, батченные work items, waiters для вызовов, которым нужно дождаться завершения drain, и pending child promises, созданные вложенной async-работой.
Если активного drain нет, run создает новый и сразу его выполняет. Если run вызван во время commit notifications, работа добавляется в активный drain, а caller ждет завершения этого drain.
Самый важный случай — прямой вызов юнита внутри выполняющейся ноды:
reaction({
on: opened,
run() {
first();
second();
},
});Когда вызывается first(), kernel уже находится внутри ноды. Virentia создает child drain и выполняет его сразу. Затем управление возвращается в пользовательский код, и выполняется second(). Поэтому явные вложенные вызовы ведут себя как обычные JavaScript-вызовы, а не переносятся в следующую priority wave.
Ребра графа все равно используют очередь текущего drain. Нода может поставить downstream-работу через node.next или ctx.launch.
const gate = createNode((ctx) => {
ctx.stop();
ctx.launch(nextNode, "value");
});ctx.launch прокидывает текущие scope, page context, metadata, value и batch key в тот же drain. Это полезно для низкоуровневых адаптеров, которым нужно маршрутизировать выполнение, не притворяясь пользовательским синхронным вызовом.
Состояние транзакции и коммит
Активная транзакция хранит ожидающие записи по scope и id стора:
type Transaction = {
depth: number;
writes: WeakMap<Scope, Map<StoreId, PendingWrite>>;
scopes: Scope[];
};WeakMap изолирует записи по scope. Внутренний Map означает, что у каждого стора есть одна ожидающая запись на транзакцию. Если стор записали несколько раз, коммитится последнее pending-значение.
Чтение стора сначала проверяет транзакцию:
read store
pending value exists in current transaction -> return pending value
otherwise -> return committed scope valueКоммит разделен на две фазы:
phase 1:
apply all changed store values
collect notify callbacks
phase 2:
run notify callbacksПодписчики должны видеть закоммиченный граф, а не наполовину примененный набор store writes. Если в одной транзакции изменились два стора, оба значения устанавливаются до запуска notifications.
Notification callbacks могут поставить в очередь новую работу. Эта работа идет через активный drain и, если пишет в сторы, через новую транзакцию. Так записи состояния и реакции наблюдателей разделены без публичного batching API.
Async-границы
Если нода возвращает promise, kernel коммитит активную транзакцию до await. Продолжение возобновляется позже в том же scope и page context, но уже со свежей транзакцией.
node starts
write draft
return promise
commit current transaction
await promise
resume node
enqueue downstream workРантайм специально не держит drafts через await. Долгоживущий draft сделал бы stale writes, conflict resolution и lifetime памяти слишком сложными для объяснения и отладки. scoped сохраняет scope и причинный контекст через async-работу; он не сохраняет draft транзакции.
Lifecycle-сторы эффектов — исключение из батчинга доменного состояния. pending и inFlight являются runtime-сигналами исполнения, поэтому обновляются сразу при старте и завершении async-работы. Lifecycle-события вроде started, doneData, failData и settled остаются обычными юнитами; реакции на эти события пишут бизнес-сторы через обычный транзакционный путь.
Почему не priority layers
Virentia избегает priority scheduler, где обновления сторов, инвалидация derived-значений, lifecycle-юниты и пользовательские реакции соревнуются в скрытых слоях. Такой подход бывает мощным, но из-за него небольшое изменение формы графа может неожиданно поменять порядок выполнения.
Kernel вместо этого держится за два правила:
- явные синхронные вызовы выполняются сейчас, в порядке JavaScript;
- ребра графа ставятся в очередь текущего drain и наблюдают состояние после commit store writes.
Соседние реакции, подписанные на один source, все еще имеют детерминированный runtime order, но этот порядок не должен задавать бизнес-решения. Если порядок важен, моделируйте его явными вызовами или реагируйте на закоммиченное состояние.
Предупреждения про read-after-write между соседними реакциями и несколько sibling writes в один стор должны быть диагностикой, а не отдельной семантикой выполнения. Рантайм не должен менять порядок, чтобы "починить" такую модель: devtools или runtime diagnostics должны показать цепочку причинности, source unit, scope, какие реакции читали или писали стор, и почему результат зависит от порядка регистрации.
Границы и контекст scope
allSettled(unit, { scope }) — самая явная граница, потому что scope передается напрямую. Он запускает работу графа в заданном scope и ждет завершения асинхронной работы.
scoped(scope, fn) — короткая рамка выполнения. Он кладет scope в текущий контекст выполнения, чтобы обычный код мог читать и писать сторы. Когда callback-функция завершается, предыдущий контекст scope восстанавливается.
Если callback возвращает promise, scoped(scope, fn) сохраняет этот scope для promise-цепочки до ее завершения. Это удобно для асинхронной работы, которой владеет приложение, но scoped не стоит воспринимать как универсальный async context для любого параллельного сценария.
scoped(scope).wrap(fn) нужен для интеграций. Он один раз захватывает scope и снова открывает его, когда другая библиотека позже вызывает вашу callback-функцию.
Владельцы и очистка
Владельцы нужны, потому что модели, созданные во время выполнения, должны уметь отвязывать свою работу. Реакции, подписки и функции очистки, зарегистрированные внутри owner, привязаны к нему.
Когда вызывается dispose, Virentia запускает функции очистки и отвязывает связи графа, созданные внутри владельца. Так динамические модели не оставляют реакции после закрытия модального окна, вкладки или кешированного экрана.
Практическая польза
Большая часть прикладного кода не должна думать о нодах. Она должна говорить на языке сторов, событий, эффектов, реакций, скоупов и владельцев.
Модель нод важна, когда вы строите bindings для фреймворков, слой совместимости, helpers для persistence, тестовые helpers, devtools или новый примитив. На этом уровне вопросы всегда одни: какая нода запускается, какой payload идет дальше, какой scope владеет значениями и кто потом очищает связи.