@statedelta-axiom/view-state
v0.1.0
Published
Reactive view computation system — Pass 0 of the tick pipeline
Downloads
117
Readme
@statedelta-actions/view-state
Sistema de Views reativas — Pass 0 do pipeline de tick (PPP: Preparar → Processar → Propagar).
Views transformam dados brutos de estado em sinais semânticos pré-computados e cacheados por departamento. Rules de domínio consomem esses sinais em vez de derivar dados inline — separação entre derivação de dados e lógica de negócio.
// SEM views — rule precisa derivar tudo inline, sem acesso a transição
{
id: "critical-heal",
when: (ctx) => /* como saber se ACABOU de ficar low? impossível sem transição */,
}
// COM views — rule consome sinal já computado
{
id: "critical-heal",
when: (ctx) => ctx.get("combat", "$justBecameLowHp") === true,
}Filosofia
View = Departamento Nomeado
Uma View não é uma lista flat de computeds. É um departamento — um Record state nomeado com computed operations que populam esse Record. Cada departamento tem escopo isolado, prioridade de computação, e condição de ativação (when).
Views inativas (when: false) têm zero custo. O evaluator nem olha pras ops.
Computeds = Declarações Reativas
Computed operations são declarações reativas de derivação. Cada op produz um valor — a op não muta a view, ela retorna algo e o evaluator escreve no Record da view. São como diretivas reativas passivas: computam dados e o sistema persiste o resultado.
Três fast paths, qualificados em register-time:
| resolve | directives | Fast Path | Comportamento |
|---------|-----------|-----------|---------------|
| Ausente | Ausente | BIND | Projeção direta: get(deps[0]) (interno do evaluator) |
| Presente | Ausente | NATIVE | Função do consumer: resolve(ctx) |
| Ausente | Presente | ACTION | Via ActionEngine: hidden action view:{viewId}:{name} |
| Presente | Presente | — | Erro de validação — mutuamente exclusivos |
Ops BIND e NATIVE resolvem nativamente — não tocam no ActionEngine. Apenas ops com directives registram hidden actions no engine compartilhado.
Evaluator = Stateless
O evaluator não possui estado de dados. Não cria stores, não gerencia Records internamente.
- Records das views são criados via
createRecordcallback (DI). O consumer fornece a factory — tipicamente o Store do@statedelta-apex. O evaluator chama a factory noregister()e persiste resultados no Record viarecord.set(name, value). - Leitura interna (
get) vem via accessor genérico passado noevaluate(). Uso exclusivo interno: state dep detection e fast path BIND. Não é passado às ops. - Leitura das ops (
ctx) vem via contexto opacoTCtxpassado noevaluate(). O consumer monta ctx comctx.get(),ctx.getDelta(), o que precisar. A lib não sabe e não precisa saber. - Dirty checking (cache reativo) vem via callback
hasChangedopcional na config. O consumer implementa (tipicamente usando TransitionState do RS). Sem callback, todas as ops recomputam a cada evaluate.
O evaluator é uma função de transformação: recebe accessors, computa ops na ordem, escreve nos Records, faz lock. Sem side-effects próprios, sem estado persistente.
Três Canais Separados (ADR-038)
| Canal | Direção | Interface | Fornecido por | Usado pra |
|-------|---------|-----------|---------------|-----------|
| ctx | Leitura (ops) | TCtx (opaco) | Consumer (evaluate arg) | resolve(ctx), when(ctx) |
| get | Leitura (interno) | (path) => unknown | Consumer (evaluate arg) | State dep detection, BIND |
| Record | Escrita | IViewRecord.set(key, value) | Factory (register-time) | Persistir resultados |
Reatividade Posicional, Não Baseada em Grafo
O evaluate é um sweep linear com dirty checking — não usa grafo de reatividade. A ordem de avaliação é fixa (definida no registro): priority entre views, topo sort dentro de cada view. Cada op consulta um changed set pra decidir se recomputa. Sem push, sem subscription, sem grafo de invalidação.
Mais próximo de um game loop: update tudo frame a frame, na ordem, com early-exit pra quem não mudou.
Opt-in Progressivo
O sistema de views segue o mesmo princípio do monorepo:
- Sem views — rules derivam dados inline. Funciona, mas polui lógica de domínio.
- Com views (BIND/NATIVE) — 90% dos casos. Projeções e funções puras. Sem engine.
- Com views (ACTION) — ops complexas via ActionEngine. Analyzer vê no mesmo grafo. Composition control garante read-only.
Feature not used = zero overhead.
Instalação
pnpm add @statedelta-actions/view-stateRequer @statedelta-actions/actions como dependência.
Quick Start
import { createActionEngine } from "@statedelta-actions/actions";
import { createViewEvaluator } from "@statedelta-actions/view-state";
// 1. ActionEngine compartilhado (mesmo usado por rules/events)
const actionEngine = createActionEngine({ handlers: { /* ... */ } });
// 2. Factory de Records (consumer fornece — ex: createRecord do @statedelta-apex)
const createRecord = (viewId: string) => ({
_data: {} as Record<string, unknown>,
_locked: false,
set(key: string, value: unknown) {
if (this._locked) throw new Error(`Record "${viewId}" is locked`);
this._data[key] = value;
},
get(key: string) { return this._data[key]; },
has(key: string) { return key in this._data; },
lock() { this._locked = true; },
unlock() { this._locked = false; },
});
// 3. Criar evaluator — TCtx é o tipo do contexto do consumer
const evaluator = createViewEvaluator<MyCtx>({ actionEngine, createRecord });
// 4. Registrar views
evaluator.register([
{
id: "combat",
priority: 100,
when: (ctx) => ctx.get("game:zone") === "combat",
computed: [
// Bind implícito — projeção direta (evaluator resolve internamente)
{ name: "hp", deps: ["units:player-1:hp"] },
{ name: "hpMax", deps: ["units:player-1:hpMax"] },
// Resolve nativo — recebe ctx, retorna valor
{ name: "$hpPercent", deps: ["hp", "hpMax"],
resolve: (ctx) =>
(ctx.get("combat:hp") as number) / (ctx.get("combat:hpMax") as number),
},
// Edge — transição detectada via ctx.getDelta
{ name: "$damaged", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) < 0;
},
},
],
},
]);
// 5. Avaliar (chamado pelo TickRunner a cada tick)
// ctx: contexto do consumer (opaco pro evaluator)
// get: accessor interno — state dep detection + BIND
evaluator.evaluate(ctx, get);
// 6. Resultado no Record da view
const record = evaluator.getRecord("combat");
record.get("hp"); // 75
record.get("$hpPercent"); // 0.75
record.get("$damaged"); // true (hp diminuiu desde o tick anterior)ViewDefinition
Uma View é um departamento nomeado. Contém metadados e computed operations.
interface ViewDefinition<TCtx = unknown> {
readonly id: string;
readonly priority: number;
readonly when?: (ctx: TCtx) => boolean;
readonly computed: readonly ComputedOp<TCtx>[];
readonly tags?: readonly string[];
readonly declarations?: Record<string, unknown>;
readonly metadata?: Record<string, unknown>;
}priority
Maior número = executa primeiro (como z-index). Views são sorted por priority desc no registro. Se view combat (priority 100) precisa estar pronta antes de ui (priority 50), combat roda primeiro.
when
Guard da view inteira. Recebe ctx: TCtx — o contexto de domínio do consumer. Se false, todas as ops da view são skippadas — zero custo. Reavaliado a cada tick.
Se when lançar exceção, a view é skippada (tratada como false).
computed
Lista de computed operations. Dentro da view, a ordem de avaliação é determinada por topological sort dos deps — não pela ordem de declaração. Ciclo entre ops da mesma view = erro fatal no registro.
ComputedOp — Tipo Unificado
Um tipo, três campos ortogonais. Sem dispatch por type — o runtime qualifica por presença de campos em register-time.
interface ComputedOp<TCtx = unknown> {
readonly name: string;
readonly deps: readonly string[];
readonly when?: (ctx: TCtx) => boolean;
readonly resolve?: (ctx: TCtx) => unknown;
readonly directives?: readonly Record<string, unknown>[];
readonly tags?: readonly string[];
readonly declarations?: Record<string, unknown>;
readonly metadata?: Record<string, unknown>;
}name
Nome da op dentro da view. Deve ser único dentro da view. O nome qualificado é {viewId}:{name} — ex: "combat:$hpPercent".
Convenção: ops derivadas usam prefixo $ — $hpPercent, $damaged, $isLowHp. Binds usam nome direto — hp, hpMax, members.
deps
Paths dos quais a op depende. O mecanismo central de reatividade: se nenhum dep mudou, a op é skippada (cache nível 1).
Deps podem ser:
- Locais (nome curto, sem
:): referência a outra op da mesma view. Normalizado em register-time pra path qualificado.deps: ["hp"]dentro de view"combat"→deps: ["combat:hp"]. - Cross-view (path qualificado com
:): lê op de outra view.deps: ["combat:$hpPercent"]lê da view"combat". Se a view já foi computada (priority maior), valor fresco. Se não, valor do tick anterior. - Estado externo (path com
:): lê estado do sistema.deps: ["units:player-1:hp"]lê do router/accessor.
O evaluator não distingue — deps são strings. O get accessor resolve.
when (guard)
Condicional secundária — avaliada após verificação de deps changed. Recebe ctx: TCtx. Se false, a op mantém valor anterior (cache). Sempre boolean, nunca usado como valor de retorno.
// Guard: só computa se hp mudou pra baixo
{ name: "$hitCount", deps: ["$damaged"],
when: (ctx) => ctx.getDelta("combat:$damaged").to === true,
resolve: (ctx) => (ctx.getDelta("combat:$hitCount").from ?? 0) + 1,
}Se when lançar exceção, a op é skippada.
resolve (caminho nativo)
Função que recebe ctx: TCtx e retorna o valor da op. O consumer monta ctx com get(), getDelta(), e o que mais precisar. A lib não conhece a estrutura do ctx.
// Derived — função pura via ctx
{ name: "$hpPercent", deps: ["hp", "hpMax"],
resolve: (ctx) =>
(ctx.get("combat:hp") as number) / (ctx.get("combat:hpMax") as number),
}
// Edge — transição via ctx.getDelta
{ name: "$damaged", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) < 0;
},
}directives (caminho ActionEngine)
Lista de diretivas que são registradas como hidden action view:{viewId}:{name} no ActionEngine compartilhado. O resultado da hidden action (result.data) é o valor da op.
{ name: "$teamPower", deps: ["units:team-1:members"],
directives: [
{ type: "action", id: "query:team-power", as: "power" },
{ type: "return", resolve: (ctx, scope) => ({ value: scope.power }) },
],
}resolve e directives são mutuamente exclusivos. Se ambos presentes, erro de validação no registro.
Hidden actions view:* devem ser read-only — sem state, emit, notify. Segurança via Analyzer (readonly propagator + composition control).
Combinações
| resolve | directives | Comportamento |
|---------|-----------|---------------|
| Ausente | Ausente | Bind implícito: evaluator retorna get(deps[0]) internamente. Requer ao menos 1 dep. |
| Presente | Ausente | Função nativa: retorna resolve(ctx). |
| Ausente | Presente | ActionEngine: cria hidden action, retorna result.data. |
| Presente | Presente | Erro — mutuamente exclusivos. |
Registro
const result = evaluator.register(views);Pipeline de registro
Para cada ViewDefinition:
│
├── 1. Valida view (id, priority, computed não-vazio)
│
├── 2. Valida cada op (name, deps, resolve/directives exclusivos)
│ Duplicata de name na mesma view → erro
│
├── 3. Normaliza deps locais → qualificados
│ "hp" dentro de view "combat" → "combat:hp"
│ "units:player:hp" → mantido (já qualificado)
│
├── 4. Topo sort dentro da view (Kahn's algorithm)
│ Ciclo → erro fatal, view rejeitada
│
├── 5. Qualifica fast path de cada op (BIND / NATIVE / ACTION)
│ ACTION → monta ActionDefinition, registra no ActionEngine
│
├── 6. Cria Record via createRecord(viewId)
│
└── 7. Armazena view + rebuilda sorted views (priority desc)ViewRegisterResult
interface ViewRegisterResult {
readonly registered: readonly string[];
readonly errors: readonly ViewRegisterError[];
readonly warnings: readonly RegisterWarning[];
}
interface ViewRegisterError {
readonly viewId: string;
readonly opName?: string;
readonly code: string;
readonly message: string;
}Códigos de erro
| Código | Quando |
|--------|--------|
| INVALID_VIEW | id vazio, priority inválido, computed vazio |
| INVALID_OP | name vazio, deps não-array, resolve+directives, bind sem deps |
| DUPLICATE_ID | View com id já registrado |
| DUPLICATE_OP | Duas ops com mesmo name dentro da mesma view |
| CYCLE_DETECTED | Ciclo de deps entre ops da mesma view |
| ACTION_REGISTER_ERROR | Falha ao registrar hidden action no ActionEngine |
Exemplos de registro
// View simples — binds e derived
evaluator.register([{
id: "combat",
priority: 100,
computed: [
{ name: "hp", deps: ["units:player-1:hp"] },
{ name: "hpMax", deps: ["units:player-1:hpMax"] },
{ name: "$hpPercent", deps: ["hp", "hpMax"],
resolve: (ctx) =>
(ctx.get("combat:hp") as number) / (ctx.get("combat:hpMax") as number),
},
],
}]);
// View com guard e edge
evaluator.register([{
id: "combat",
priority: 100,
when: (ctx) => ctx.get("game:zone") === "combat",
computed: [
{ name: "hp", deps: ["units:player-1:hp"] },
{ name: "$damaged", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) < 0;
},
},
{ name: "$hitCount", deps: ["$damaged"],
when: (ctx) => ctx.getDelta("combat:$damaged").to === true,
resolve: (ctx) => (ctx.getDelta("combat:$hitCount").from ?? 0) + 1,
},
],
}]);Desregistrar
const removed = evaluator.unregister("combat"); // true se encontradoRemove a view de todos os registros internos. Unregistra hidden actions do ActionEngine. O Record da view é descartado.
Avaliação (evaluate / evaluateAsync)
// Sync (default)
evaluator.evaluate(ctx, get);
// Async (quando ActionEngine tem hooks async)
await evaluator.evaluateAsync(ctx, get);Se o ActionEngine conectado tiver hooks async (actionEngine.isAsync === true),
evaluate() lança erro — use evaluateAsync(). O evaluator herda o flag
de async do ActionEngine (evaluator.isAsync). Ops BIND e NATIVE são sempre
síncronas — só ops ACTION usam invokeAsync() no path async.
Parâmetros
| Parâmetro | Tipo | Descrição |
|-----------|------|-----------|
| ctx | TCtx | Contexto de domínio do consumer. Passado às ops via resolve(ctx) e when(ctx). Opaco pro evaluator. |
| get | (path: string) => unknown | Accessor interno — state dep detection e fast path BIND. Não é passado às ops. |
O ctx
Contexto opaco do consumer. O evaluator passa ctx pros ops (resolve(ctx), when(ctx)) sem interpretar. O consumer monta ctx com get(), getDelta(), e o que mais precisar:
// Consumer monta ctx
const ctx = {
get: (path) => router(path),
getDelta: (path) => transitionState.delta(path),
};
// get é pra uso interno do evaluator
const get = ctx.get;
evaluator.evaluate(ctx, get);O get interno
Accessor genérico que lê qualquer path acessível no sistema. Uso exclusivo interno do evaluator — state dep detection e fast path BIND. Não é passado às ops. Ops usam ctx.
O consumer é responsável por fazer ctx.get() resolver paths de views lendo dos Records — é wiring externo, não concern do evaluator.
O sweep linear
evaluate(ctx, get):
changed = Set<string> // paths que mudaram (global, entre views)
for cada view (priority desc):
// Guard da view
if view.when && !view.when(ctx):
continue // skip total, zero custo
// Unlock record (locked do tick anterior)
record.unlock()
// 1. Detectar mudanças em state paths desta view
for cada state path nos deps:
if !hasChanged || hasChanged(path, get(path)):
changed.add(path)
// 2. Avaliar ops em ordem topológica
for cada op:
// Cache nível 1: algum dep mudou?
if !hasChanged:
// sem dirty checker → sempre executa (sem cache)
else:
if nenhum dep no changed set:
continue // skip — deps unchanged
// Guard da op
if op.when && !op.when(ctx):
continue // skip — guard falhou
// Resolve por fast path
switch op.fastPath:
BIND: result = get(op.deps[0])
NATIVE: result = op.resolve(ctx)
ACTION: result = actionEngine.invoke(...).data // sync
(await actionEngine.invokeAsync(...)).data // async
// Escreve no Record da view (chave local)
record.set(op.name, result)
// Cache nível 2: valor mudou?
if !hasChanged || hasChanged(qualifiedName, result):
changed.add(qualifiedName) // propaga pra dependentes
// 3. Lock — Record read-only até o próximo tick
record.lock()Cache em dois níveis
Cache reativo requer hasChanged na config. Sem hasChanged, todas as ops recomputam a cada evaluate (sem cache — comportamento padrão).
Nível 1 — Deps unchanged. Nenhum dep mudou (via hasChanged). Skip direto — nem invoca resolve/action. Reutiliza valor anterior no Record. Custo: O(deps.length) — iteração + lookup no changed set.
Nível 2 — Value firewall. Deps mudaram, op executou, mas resultado é o mesmo (via hasChanged). Dependentes não recomputam — cascata cortada.
hp: 70 (mudou de 100)
→ $hpPercent recomputa: 70/100 = 0.7 (era 0.8) → MUDOU
→ $isLowHp recomputa: 0.7 < 0.2 = false (era false) → NÃO MUDOU (firewall)
→ $justBecameLowHp: deps não mudaram → SKIP (nível 1)Comparação shallow (===). Primitivos direto. Objetos dependem de structural sharing.
Primeiro tick (sem cache)
Sem hasChanged configurado (ou antes do RS popular o TransitionState), todos os computeds executam — sem cache.
Ops com ctx.getDelta() retornam { changed: false } no primeiro tick — sem transição detectável. Isso é correto: não houve mudança, houve inicialização.
Após o primeiro tick, o RS/Factory captura valores no TransitionState pra habilitar cache no próximo tick.
Error resilience
Erros durante evaluate são reportados via onEvalError callback (configurável). Se o callback retorna, a op é skippada. Se lança, o evaluate é interrompido. Sem callback, skip silencioso.
| Fonte do erro | source | Comportamento |
|---------------|--------|---------------|
| view.when() throws | view-when | View skippada (tratada como false) |
| op.when() throws | op-when | Op skippada |
| get(deps[0]) throws | bind | Op BIND skippada |
| op.resolve() throws | native | Op skippada |
| actionEngine.invoke() throws | action | Op skippada |
| actionEngine.invoke() retorna erros | action | Op skippada (engine capturou internamente) |
const evaluator = createViewEvaluator<MyCtx>({
actionEngine,
createRecord,
onEvalError: (err) => {
console.warn(`[${err.source}] ${err.viewId}:${err.opName}`, err.error);
// retorna → op skippada
// throw → interrompe evaluate
},
});IViewRecord — Interface do Record
Interface mínima que o evaluator precisa pro Record de cada view. O consumer fornece a implementação real (ex: IRecordState do @statedelta-apex/record-state).
interface IViewRecord {
set(key: string, value: unknown): void;
get(key: string): unknown;
has(key: string): boolean;
lock(): void;
unlock(): void;
}| Método | Quando | Quem chama |
|--------|--------|------------|
| set(key, value) | Escrever resultado computado | Evaluator, durante evaluate |
| get(key) | Ler valor já computado | Consumer (via ctx wiring) |
| has(key) | Verificar existência | Consumer (via ctx wiring) |
| lock() | Tornar read-only após avaliar a view | Evaluator |
| unlock() | Liberar escrita antes de reavaliar no próximo tick | Evaluator |
createRecord callback
O evaluator recebe createRecord via config:
interface ViewEvaluatorConfig<TCtx> {
readonly actionEngine: IActionEngine<TCtx>;
readonly createRecord: (viewId: string) => IViewRecord;
readonly onEvalError?: OnEvalError; // callback de erro no evaluate
}Chamado durante register() — um Record por view. O evaluator não sabe como o Record funciona internamente. Pode ser um IRecordState do apex, pode ser um Map wrapper, pode ser qualquer coisa que implemente IViewRecord.
// Exemplo: consumer usando @statedelta-apex
import { createRecord as createApexRecord } from "@statedelta-apex/record-state";
const evaluator = createViewEvaluator({
actionEngine,
createRecord: (viewId) => {
const record = createApexRecord(viewId, {});
return record; // IRecordState implementa IViewRecord
},
});Integração com ActionEngine
Engine compartilhado
Views, rules e events usam o mesmo ActionEngine. Hidden actions de ops ACTION coexistem no mesmo registry:
ActionEngine (registry unificado)
│
├── view:combat:$teamPower ← ViewEvaluator (ACTION)
│ (ops BIND e NATIVE não registram no engine)
│
├── query:team-power ← Actions read-only (reutilizáveis)
│
├── rule:combat-heal ← RuleEngine
├── event:on-healed ← EventProcessor
│
└── Analyzer valida tudo no mesmo grafoDiretivas permitidas para ops ACTION
Hidden actions view:* devem ser read-only. Só diretivas puras:
let/const— binding no scopereturn— resultado do computedaction— invocar query actions read-only
Side-effects (state, emit, notify, halt, throw) são proibidos. Segurança via Analyzer:
// Manifest rule — analyzer rejeita se view:* invocar ação com side-effects
{
source: { tags: { include: ["view"] } },
target: { properties: { readonly: false } },
effect: "deny",
message: "computed views can only invoke read-only actions",
}Query actions
Computeds podem invocar query actions — actions read-only registradas no engine compartilhado:
// Query registrada uma vez, usada por computeds e rules
actionEngine.register([{
id: "query:team-stats",
directives: [
{ let: "members", resolve: (ctx, scope) =>
({ value: scope.get("units:team-1:members") }) },
{ let: "total", resolve: (ctx, scope) =>
({ value: scope.members.reduce((sum, id) =>
sum + scope.get(`units:${id}:power`), 0) }) },
{ type: "return", resolve: (ctx, scope) =>
({ value: { total: scope.total } }) },
],
}]);
// Computed consome a query
{ name: "$teamPower", deps: ["units:team-1:members"],
directives: [
{ type: "action", id: "query:team-stats", as: "stats" },
{ type: "return", resolve: (ctx, scope) => ({ value: scope.stats }) },
],
}Action memo é transparente pro evaluator
Se o ActionEngine tiver action hooks de memoização, hidden actions view:* podem ser memoizadas pelo engine. O evaluator não sabe — invoca normalmente e recebe o resultado (cacheado ou não). São dois caches independentes:
- View cache (evaluator) — deps unchanged → não invoca a action
- Action memo (engine) — action invocada mas deps do memo unchanged → engine retorna último resultado sem executar diretivas
Dependências entre Views
Views podem depender de qualquer estado ou view. Não existe mecanismo especial de cross-view.
Acesso uniforme
O ctx.get() do consumer resolve tudo da mesma forma:
ctx.get("units:player-1:hp") // estado normal
ctx.get("combat:$hpPercent") // op da view "combat"
ctx.get("team:$stats") // op de outra viewFrescor por priority
Se view combat (priority 100) já foi computada e view ui (priority 50) depende de combat:$hpPercent, lê valor fresco — combat rodou primeiro.
Se view ui dependesse de view com priority menor (ainda não computada), leria valor do tick anterior (via accessor/ctx). Determinístico, não quebra, não crasha.
evaluator.register([
// Priority 100 — computa primeiro
{
id: "combat",
priority: 100,
computed: [
{ name: "hp", deps: ["units:player-1:hp"] },
{ name: "$pct", deps: ["hp"],
resolve: (ctx) => (ctx.get("combat:hp") as number) / 100,
},
],
},
// Priority 50 — combat já computou quando ui roda
{
id: "ui",
priority: 50,
computed: [
// Cross-view: lê $pct da view "combat"
{ name: "hpBar", deps: ["combat:$pct"] },
{ name: "$color", deps: ["hpBar"],
resolve: (ctx) =>
(ctx.get("ui:hpBar") as number) < 0.3 ? "red" : "green",
},
],
},
]);Normalização de Deps
Deps locais (sem :) são expandidos pra path qualificado em register-time:
View "combat":
{ name: "$isLowHp", deps: ["$hpPercent"] }
→ normalizado: deps: ["combat:$hpPercent"]
{ name: "hp", deps: ["units:player-1:hp"] }
→ mantido: deps: ["units:player-1:hp"]
{ name: "$showTeamWipe", deps: ["team:$teamWiped"] }
→ mantido: deps: ["team:$teamWiped"]A regra: se o dep contém : → mantém. Se não contém : e é nome de outra op da mesma view → qualifica com {viewId}:. Se não é nome de op local → mantém como está (dep externo sem : — ex: "$tick").
Integração com TickRunner (Pass 0)
O TickRunner consome o ViewEvaluator via interface IViewComputer:
interface IViewComputer<TCtx = unknown> {
evaluate(ctx: TCtx, get: (path: string) => unknown): void;
evaluateAsync(ctx: TCtx, get: (path: string) => unknown): Promise<void>;
}TR.run(ctx): TR.runAsync(ctx):
│ │
├── Pass 0: ├── Pass 0:
│ viewComputer.evaluate(...) │ await viewComputer.evaluateAsync(...)
│ │
├── Pass 1: ├── Pass 1:
│ ruleEngine.evaluate(ctx) │ await ruleEngine.evaluateAsync(ctx)
│ │
└── Pass 2+: drain de eventos └── Pass 2+: drain asyncEdge Patterns e Delta (vocabulário DSL)
Edge (boolean de transição) e delta (objeto de mudança) são vocabulário do DSL JSON — não do runtime. O compilador DSL → JS normaliza pra ComputedOp com resolve.
O runtime não conhece "edge" ou "delta". Recebe ComputedOp com resolve ou directives.
Edge — compilado do DSL
// DSL JSON:
{ name: "$damaged", edge: "decrease", deps: ["hp"] }
// Compila pra:
{ name: "$damaged", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) < 0;
},
}
// DSL com edge avançado:
{ name: "$lowHp", deps: ["hp"], edge: { op: "crossBelow", value: 20 } }
// Compila pra:
{ name: "$lowHp", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.from as number) >= 20 && (d.to as number) < 20;
},
}Delta — compilado do DSL
// DSL JSON:
{ name: "$hpDelta", delta: true, deps: ["hp"] }
// Compila pra:
{ name: "$hpDelta", deps: ["hp"],
resolve: (ctx) => ctx.getDelta("combat:hp"),
}Full API Reference
IViewEvaluator<TCtx>
interface IViewEvaluator<TCtx = unknown> {
// Registration
register(views: readonly ViewDefinition<TCtx>[]): ViewRegisterResult;
unregister(id: string): boolean;
// Evaluation
evaluate(ctx: TCtx, get: (path: string) => unknown): void;
evaluateAsync(ctx: TCtx, get: (path: string) => unknown): Promise<void>;
// Introspection
has(id: string): boolean;
readonly size: number;
readonly isAsync: boolean;
getRecord(viewId: string): IViewRecord | undefined;
readonly actionEngine: IActionEngine<TCtx>;
/**
* Coleta todos os state paths observados por todas as views registradas.
* Paths externos (deps que não são ops locais da view).
* Usado pelo consumer (RS) pra gerenciar o TransitionStore.
*/
collectStateDeps(): ReadonlySet<string>;
}ViewEvaluatorConfig<TCtx>
interface ViewEvaluatorConfig<TCtx = unknown> {
readonly actionEngine: IActionEngine<TCtx>;
readonly createRecord: (viewId: string) => IViewRecord;
readonly onEvalError?: OnEvalError;
readonly hasChanged?: HasChangedFn;
}
/**
* Callback de dirty checking — informa se um path mudou.
* Recebe o path (qualificado) e o valor atual.
* Retorna true se o valor mudou em relação ao anterior.
* Sem este callback, todas as ops recomputam a cada evaluate.
*/
type HasChangedFn = (path: string, currentValue: unknown) => boolean;
type OnEvalError = (error: ViewEvalError) => void;
interface ViewEvalError {
readonly viewId: string;
readonly opName?: string;
readonly qualifiedName?: string;
readonly source: EvalErrorSource;
readonly error: unknown;
}
type EvalErrorSource = "view-when" | "op-when" | "bind" | "native" | "action";ViewDefinition<TCtx>
interface ViewDefinition<TCtx = unknown> {
readonly id: string;
readonly priority: number;
readonly when?: (ctx: TCtx) => boolean;
readonly computed: readonly ComputedOp<TCtx>[];
readonly tags?: readonly string[];
readonly declarations?: Record<string, unknown>;
readonly metadata?: Record<string, unknown>;
}ComputedOp<TCtx>
interface ComputedOp<TCtx = unknown> {
readonly name: string;
readonly deps: readonly string[];
readonly when?: (ctx: TCtx) => boolean;
readonly resolve?: (ctx: TCtx) => unknown;
readonly directives?: readonly Record<string, unknown>[];
readonly tags?: readonly string[];
readonly declarations?: Record<string, unknown>;
readonly metadata?: Record<string, unknown>;
}IViewRecord
interface IViewRecord {
set(key: string, value: unknown): void;
get(key: string): unknown;
has(key: string): boolean;
lock(): void;
unlock(): void;
}HasChangedFn
type HasChangedFn = (path: string, currentValue: unknown) => boolean;CreateRecordFn
type CreateRecordFn = (viewId: string) => IViewRecord;Exports
// Factory
export { createViewEvaluator } from "./evaluator";
// Types
export type {
IViewRecord,
CreateRecordFn,
ComputedOp,
ViewDefinition,
ViewRegisterResult,
ViewRegisterError,
RegisterWarning,
HasChangedFn,
ViewEvaluatorConfig,
IViewEvaluator,
IViewComputer,
ViewEvalError,
EvalErrorSource,
OnEvalError,
} from "./types";Use Case: RPG — View de Combate
const evaluator = createViewEvaluator<MyCtx>({ actionEngine, createRecord });
evaluator.register([{
id: "combat",
priority: 100,
when: (ctx) => ctx.get("game:zone") === "combat",
computed: [
// Bind — projeções diretas de estado
{ name: "hp", deps: ["units:player-1:hp"] },
{ name: "hpMax", deps: ["units:player-1:hpMax"] },
// Derived — função pura
{ name: "$hpPercent", deps: ["hp", "hpMax"],
resolve: (ctx) =>
(ctx.get("combat:hp") as number) / (ctx.get("combat:hpMax") as number),
},
// Edge — transição detectada
{ name: "$damaged", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) < 0;
},
},
{ name: "$healed", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) > 0;
},
},
// Delta — objeto de mudança
{ name: "$hpDelta", deps: ["hp"],
resolve: (ctx) => ctx.getDelta("combat:hp"),
},
// Derived de derived
{ name: "$isLowHp", deps: ["$hpPercent"],
resolve: (ctx) => (ctx.get("combat:$hpPercent") as number) < 0.2,
},
// Edge de derived — "acabou de ficar low?"
{ name: "$justBecameLowHp", deps: ["$isLowHp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:$isLowHp");
return d.changed && d.to === true;
},
},
// Contador com guard
{ name: "$hitCount", deps: ["$damaged"],
when: (ctx) => ctx.getDelta("combat:$damaged").to === true,
resolve: (ctx) => (ctx.getDelta("combat:$hitCount").from ?? 0) + 1,
},
],
}]);Use Case: Cross-view — UI consumindo Combat
evaluator.register([
{
id: "combat",
priority: 100,
computed: [
{ name: "hp", deps: ["units:player-1:hp"] },
{ name: "$pct", deps: ["hp"],
resolve: (ctx) => (ctx.get("combat:hp") as number) / 100,
},
{ name: "$damaged", deps: ["hp"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:hp");
return d.changed && (d.diff ?? 0) < 0;
},
},
],
},
{
id: "ui",
priority: 50, // menor que combat → combat já computou
computed: [
// Cross-view: lê da view "combat"
{ name: "hpBar", deps: ["combat:$pct"] },
{ name: "$showDamageFlash", deps: ["combat:$damaged"],
resolve: (ctx) => {
const d = ctx.getDelta("combat:$damaged");
return d.changed && d.to === true;
},
},
// Local: depende de op da própria view
{ name: "$hpBarColor", deps: ["hpBar"],
resolve: (ctx) =>
(ctx.get("ui:hpBar") as number) < 0.2 ? "red" : "green",
},
],
},
]);Use Case: View com Query Action (directives)
evaluator.register([{
id: "team",
priority: 200,
computed: [
// Op com directives → hidden action no engine
{ name: "$stats", deps: ["units:team-1:members"],
directives: [
{ type: "action", id: "query:team-stats", as: "stats" },
{ type: "return", resolve: (ctx, scope) => ({ value: scope.stats }) },
],
},
// Resolve nativo — depende de op ACTION
{ name: "$teamWiped", deps: ["$stats"],
resolve: (ctx) => {
const d = ctx.getDelta("team:$stats");
return d.changed && d.to === true;
},
},
],
}]);Use Case: Rules consumindo Views
// Após evaluate(), rules consomem os sinais pré-computados
ruleEngine.register([
{
id: "critical-heal",
priority: 100,
when: (ctx) => ctx.get("combat", "$justBecameLowHp") === true,
then: [
{ type: "state", target: "units:player-1:hp", op: "set", value: 50 },
{ type: "emit", event: "emergency-heal" },
],
},
{
id: "combo-bonus",
priority: 50,
when: (ctx) => ctx.get("combat", "$hitCount") >= 5,
then: [
{ type: "state", target: "units:player-1:combo", op: "set", value: true },
],
},
]);Performance
Spectrum
Zero views BIND/NATIVE only ACTION (directives)
────────────────────────────────────────────────────────────────
Zero overhead Sweep linear, funções + ActionEngine invoke
puras, O(deps) cache + hidden action compileO que é O(1) e o que não é
| Operação | Custo |
|----------|-------|
| Register | O(ops) — validação + topo sort + qualificação |
| Evaluate (tick) | O(views × ops) — sweep linear com cache |
| Cache nível 1 check | O(deps.length) por op |
| BIND resolve | O(1) — get(deps[0]) |
| NATIVE resolve | O(resolve) — depende da função |
| ACTION resolve | O(invoke) — depende do ActionEngine |
| Cross-view read | O(1) — mesmo get |
Cache effectiveness
A maioria dos ticks, a maioria das ops é skippada pelo cache nível 1 (deps unchanged). Quando deps mudam, o cache nível 2 (value firewall) corta cascatas — ops que recomputam mas produzem o mesmo resultado não propagam.
v0 Limitations
- Sem JIT — sweep interpretado. JIT como evolução se performance exigir.
- Sem tracking dinâmico — deps estáticos declarados. Tracking dinâmico é evolução futura.
- Sem grafo de reatividade — sweep linear visita todas as ops de views ativas. Grafo é otimização interna futura (sem impacto na API).
Module Structure
src/
├── index.ts — Public exports
├── types.ts — All types and interfaces
├── evaluator.ts — createViewEvaluator + ViewEvaluatorImpl
├── topo-sort.ts — Kahn's algorithm (per-view)
└── validate.ts — validateView, validateOpLicença
MIT
