npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 createRecord callback (DI). O consumer fornece a factory — tipicamente o Store do @statedelta-apex. O evaluator chama a factory no register() e persiste resultados no Record via record.set(name, value).
  • Leitura interna (get) vem via accessor genérico passado no evaluate(). Uso exclusivo interno: state dep detection e fast path BIND. Não é passado às ops.
  • Leitura das ops (ctx) vem via contexto opaco TCtx passado no evaluate(). O consumer monta ctx com ctx.get(), ctx.getDelta(), o que precisar. A lib não sabe e não precisa saber.
  • Dirty checking (cache reativo) vem via callback hasChanged opcional 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-state

Requer @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 encontrado

Remove 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 grafo

Diretivas permitidas para ops ACTION

Hidden actions view:* devem ser read-only. Só diretivas puras:

  • let / const — binding no scope
  • return — resultado do computed
  • action — 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 view

Frescor 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 async

Edge 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 compile

O 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, validateOp

Licença

MIT