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-actions/rules

v0.7.0

Published

Rule evaluation engine with JIT-optimized sequential evaluation and sub-rules

Downloads

1,221

Readme

@statedelta-actions/rules

Rule Engine — camada superset sobre o ActionEngine. Execução condicional de triggers por prioridade.

Rule = trigger. Eventos são um package separado (@statedelta-actions/events).


Índice

  1. Filosofia
  2. Visão Geral da Arquitetura
  3. Instalação & Setup
  4. Início Rápido
  5. Definição de Rule
  6. Registro
  7. Avaliação de Triggers
  8. Sub-rules (Cascata Condicional)
  9. Hooks (Governança)
  10. Middleware (Enriquecimento de Params)
  11. Compilação JIT
  12. Operações em Batch
  13. Suporte Async
  14. Modo Interactive
  15. Tratamento de Erros
  16. HALT_HANDLER
  17. createInvokerMiddleware
  18. Referência Completa da API
  19. Caso de Uso: Tick de Combate RPG
  20. Caso de Uso: Pipeline de Pedido E-commerce
  21. Caso de Uso: Workflow de Aprovação de Deploy
  22. Espectro de Performance
  23. Notas de Design Interno

Filosofia

Rule = Trigger

Uma rule não é um executor. Uma rule é um invocador — um trigger condicional que, quando matched, invoca uma action através do ActionEngine. O RuleEngine não executa diretivas. Ele normaliza rules em hidden actions no momento do registro e depois delega toda a execução ao ActionEngine.

Registro:
  rule { id: "combat-heal", when: ..., then: [...] }
    -> actionEngine.register([{ id: "rule:combat-heal", directives: [...] }])

Avaliação:
  when(ctx) === true
    -> actionEngine.invoke("rule:combat-heal", params)

Após o registro, o RuleEngine é um loop de when() -> invoke().

RuleEngine recebe, não cria

O RuleEngine recebe um IActionEngine<TCtx> já instanciado e configurado. Não injeta handlers, não manipula o access manifest, não cria o ActionEngine. O consumer é responsável por:

  • Registrar handlers (dispatch, emit, halt, customizados)
  • Definir limites, modo JIT
  • Configurar o ActionAnalyzer separadamente se análise estática for necessária

Essa separação significa que o mesmo ActionEngine pode servir tanto invocações diretas de action quanto invocações dirigidas por rules.

Hooks = governança, handlers = controle de fluxo

Hooks (beforeRule, afterRule) governam o loop de rules: guards, observação, decisões de abort. Controle de fluxo dentro de uma action (halt, state locking, abort por erro) pertence aos handlers no ActionEngine. Duas camadas diferentes com responsabilidades diferentes.

Middleware = enriquecimento de params, não transformação de ctx

Middleware enriquece o envelope params (scope de execução), não o ctx de domínio. O ctx (TCtx) é estado de domínio owned pelo consumer — read-only para middleware.

Nunca lança durante a avaliação

O engine nunca lança exceções para o consumer durante evaluate(). Todos os erros de runtime são coletados em RuleEvaluationResult.errors. A única exceção é um erro de programação: chamar evaluate() quando isAsync === true.

register() é diferente — erros estruturais em boot-time (handler ausente para um tipo de diretiva) lançam um Error e abortam o registro atomicamente. Ver Registro.

Feature não usada = zero overhead

Quando hooks não são registrados, o código de hook nunca é executado — não como branches mortos, mas como código ausente. O compilador JIT emite condicionalmente apenas o código das features que de fato estão configuradas. Zero middleware significa nenhuma variável de middleware, nenhuma chamada de pipeline, nenhuma alocação de params.


Visão Geral da Arquitetura

Posição no Monorepo

@statedelta-actions/core       <- tipos compartilhados, slots, frame
        |
@statedelta-actions/actions    <- ActionEngine — runtime puro (diretivas, handlers, JIT)
        |
@statedelta-actions/rules      <- RuleEngine (este package)
@statedelta-actions/events     <- EventProcessor (package separado)

@statedelta-actions/graph      <- grafo de dependências (consumido pelo analyzer, não pelo actions/rules)
@statedelta-actions/analyzer   <- ActionAnalyzer — análise estática, capabilities (opt-in)

(futuro) tick-runner / realm    <- PPP: evaluate -> drain events -> repeat

Modelo de Composição

Consumer (tick-runner, game loop, camada de negócio)
  |
  +-- configura o ActionEngine (handlers, limites)
  +-- cria o RuleEngine(actionEngine, middleware, hooks)
  +-- registra as rules
  +-- chama evaluate(ctx)

Estrutura de Módulos

src/
+-- types.ts          <- Todos os tipos e interfaces + RuleEvaluatorFn
+-- engine.ts         <- RuleEngineImpl + createRuleEngine + helpers de registry (~470 linhas)
+-- validate.ts       <- validateRule, validateSubRule
+-- handlers.ts       <- HALT_HANDLER
+-- middleware.ts      <- createInvokerMiddleware (público) + runMiddleware (interno)
+-- index.ts          <- Re-exports públicos
+-- eval/             <- Runtime de avaliação
|   +-- interpreter.ts <- Interpretador de avaliação de rules sync/async + builders de result
|   +-- jit.ts         <- buildRuleExecutor + codegen do emitReturn
|   +-- sub-rules.ts   <- evaluateSubRulesSync/Async

Exports Públicos

// Factory
export { createRuleEngine } from "./engine";

// Handlers
export { HALT_HANDLER } from "./handlers";

// Middleware
export { createInvokerMiddleware } from "./middleware";

// Tipos
export type {
  SubRuleDefinition,
  RuleDefinition,
  RuleRegisterResult,
  RuleRegisterError,
  RuleEvaluationResult,
  RuleEvaluationContext,
  RuleEngineConfig,
  IRuleEngine,
  RuleHooks,
  RuleMiddleware,
} from "./types";

Instalação & Setup

import { createActionEngine } from "@statedelta-actions/actions";
import {
  createRuleEngine,
  HALT_HANDLER,
  createInvokerMiddleware,
} from "@statedelta-actions/rules";

// 1. Configure o ActionEngine com seus handlers
const actionEngine = createActionEngine<MyCtx>({
  handlers: {
    dispatch: myDispatchHandler,
    emit: myEmitHandler,
    halt: HALT_HANDLER,
  },
});

// 2. Crie o RuleEngine, passando o ActionEngine
const ruleEngine = createRuleEngine<MyCtx>({
  actionEngine,
  middleware: [createInvokerMiddleware()],   // opcional
  ruleHooks: {                               // opcional
    beforeRule: (rule, evalCtx) => { /* guard */ },
    afterRule: (rule, result, evalCtx) => { /* observa */ },
    onRulesComplete: (result) => { /* cleanup */ },
  },
  maxSubRuleDepth: 10,     // default
  mode: "auto",            // "interpret" | "jit" | "auto" (default)
  autoJitThreshold: 8,     // default
});

Início Rápido

// Registra as rules
ruleEngine.register([
  {
    id: "heal-when-low",
    priority: 100,
    when: (ctx) => ctx.hp < 50,
    then: [
      { type: "dispatch", target: "hp", op: "inc", value: 20 },
    ],
  },
  {
    id: "regen-mp",
    priority: 50,
    when: (ctx) => ctx.mp < 100,
    then: [
      { type: "dispatch", target: "mp", op: "inc", value: 5 },
    ],
  },
]);

// Avalia todas as rules de trigger contra o estado atual
const result = ruleEngine.evaluate(ctx);

// result.success     -> true se completou sem abort
// result.matched     -> ["heal-when-low", "regen-mp"] (IDs das rules)
// result.counters    -> { rulesEvaluated: 2, rulesMatched: 2, ... }

Definição de Rule

SubRuleDefinition

Tipo base para sub-rules. Sem priority — sub-rules executam em ordem de declaração.

interface SubRuleDefinition<TCtx> {
  readonly id: string;                                   // identificador único
  readonly when?: (ctx: TCtx) => boolean;                // condição de trigger
  readonly then?: readonly Directive<TCtx>[];            // diretivas da action
  readonly rules?: readonly SubRuleDefinition<TCtx>[];   // sub-rules aninhadas
  readonly tags?: readonly string[];
  readonly effects?: readonly string[];
  readonly declarations?: Record<string, unknown>;
  readonly metadata?: Record<string, unknown>;
}

RuleDefinition

Rules top-level estendem SubRuleDefinition com priority obrigatória.

interface RuleDefinition<TCtx> extends SubRuleDefinition<TCtx> {
  readonly priority: number;                             // maior = primeiro (ordem desc)
  readonly rules?: readonly SubRuleDefinition<TCtx>[];   // sub-rules (sem priority)
}

Uma rule precisa ter when (condição de trigger).

Uma rule precisa ter then ou rules (ou ambos). Uma rule só com rules e sem then é um group gate — um container puramente condicional.

Priority

Número maior = executa primeiro (como z-index). As rules são ordenadas por priority descendente no momento do registro. Sub-rules executam em ordem de declaração — elas não têm priority.

Contrato de ordenação (estável)

A ordem de avaliação das rules top-level é determinística e contratual — consumers podem depender; mudança = breaking:

  1. Priority descendente — maior priority avalia primeiro.
  2. FIFO entre prioridades iguais — empate resolve por ordem de registro (a rule registrada antes avalia antes). Inserção via binary insertion estável.
  3. Fonte únicaevaluate(), evaluateAsync() e evaluateInteractive() percorrem a mesma estrutura ordenada (_rules). Não há sort separado por caminho — a ordem é idêntica nos três.

Para obter essa ordem sem reconstruir (debug, devtools, tracing), use engine.getRulesOrdered() — snapshot imutável readonly RuleView[] ({ id, priority }) na ordem canônica exata de avaliação. É function (não getter): materializa O(N) por chamada — a convenção de getter aqui é acesso O(1) (size, isAsync). Ver ADR-027.

const ordered = engine.getRulesOrdered();
// [{ id: "death-check", priority: 500 }, { id: "auto-heal", priority: 100 }]
// — exatamente a sequência que evaluate() percorre

Validação

| Check | Código de Erro | |-------|-----------| | id precisa ser uma string não vazia | INVALID_RULE | | priority precisa ser um number | INVALID_RULE | | Precisa ter função when | INVALID_RULE | | Precisa ter then ou rules (ou ambos) | INVALID_RULE | | ID duplicado | DUPLICATE_ID |

Validação de sub-rule:

| Check | Código de Erro | |-------|-----------| | id precisa ser uma string não vazia | INVALID_RULE | | Precisa ter then ou rules (ou ambos) | INVALID_RULE |


Registro

const result = ruleEngine.register([rule1, rule2, ...]);

Pipeline de registro

Três fases. Os registries locais só são mutados depois que o ActionEngine aceita todas as hidden actions — register() é atômico.

  1. Build — valida cada rule (id, priority, when, then/rules), coleta sub-rules recursivamente, monta as definitions das hidden actions (rule:{id} para top-level, separadas por ponto para sub-rules). Erros de validação soft (id duplicado, campo obrigatório ausente) acumulam em errors[]. Ainda sem indexação local.
  2. Delegar ao ActionEngineactionEngine.register(hiddenActions). Erros estruturais propagam como throw (handler ausente para um tipo de diretiva). Erros soft do ActionEngine são mapeados de volta para ids de rule e adicionados a errors[].
  3. Indexar — só é alcançado quando a fase 2 retorna. Insere cada rule em _ruleRegistry e _rules (ordenado por priority desc), refresca as flags isAsync/isInteractive per-rule a partir do mini-graph.

Throw vs erros coletados

| Erro | Comportamento | |-------|----------| | Handler ausente para um tipo de diretiva em then (qualquer profundidade, incluindo sub-rules e catch) | Lança Error em register(). Nenhum estado local mutado. O consumer envolve em try/catch se precisar. | | Id de rule duplicado, when ausente, then/rules ausentes, priority inválida | Coletado em errors[]. Outras rules válidas no mesmo call ainda registram. | | validate() de um handler retorna invalid ou lança | Coletado em errors[]. |

O caminho de throw é reservado para erros estruturais de boot-time que não podem virar válidos em runtime — typos, handler esquecido no registro, resíduo de refactor. Erros soft são domain-level e merecem inspeção.

RuleRegisterResult

interface RuleRegisterResult {
  readonly registered: readonly string[];
  readonly errors: readonly RuleRegisterError[];
  readonly warnings: readonly RegisterWarning[];
}

Unregister

const removed = ruleEngine.unregister("rule-id"); // true se encontrado e removido

Remove a rule de todos os registries internos, desregistra a hidden action do ActionEngine e limpa as sub-rules recursivamente.


Avaliação de Triggers

const result = ruleEngine.evaluate(ctx);

Processa todas as rules em ordem de priority descendente:

evaluate(ctx)
  |
  actionEngine.setContext(ctx)
  |
  para cada rule (priority desc):
  |
  +-- hook beforeRule -> "skip" -> skipped | "abort" -> return | void -> continua
  +-- when(ctx) -> false -> notMatched | erro -> coleta, notMatched
  +-- matched
  +-- Pipeline de middleware -> params (ou erro -> coleta, pula invoke)
  +-- Invoca a action (se tem then) -> DirectiveResult
  +-- hook afterRule -> "abort" -> return | void -> continua
  +-- Check de halt -> aborted -> return
  +-- Cascata de sub-rules -> aborted -> return
  |
  onRulesComplete(result)
  return result

RuleEvaluationResult

interface RuleEvaluationResult {
  readonly success: boolean;            // true se completou sem abort
  readonly aborted: boolean;            // true se parou cedo
  readonly abortedBy?: string;          // "beforeRule" | "afterRule" | "sub-rule" | "halt" | handler
  readonly matched: readonly string[];  // IDs das rules matched
  readonly skipped: readonly string[];  // IDs skipados pelo beforeRule
  readonly notMatched: readonly string[];
  readonly errors: readonly RuleError[];
  readonly processedCount: number;      // rules processadas (< totalCount em abort)
  readonly totalCount: number;
  readonly counters: FrameCounters;     // acumulado em todo o aninhamento
}

Exemplo

const ruleEngine = createRuleEngine({ actionEngine });

ruleEngine.register([
  {
    id: "shield",
    priority: 200,
    when: (ctx) => ctx.inCombat,
    then: [{ type: "dispatch", target: "defense", op: "inc", value: 10 }],
  },
  {
    id: "heal",
    priority: 100,
    when: (ctx) => ctx.hp < 50,
    then: [{ type: "dispatch", target: "hp", op: "inc", value: 20 }],
  },
]);

const ctx = { inCombat: true, hp: 30, defense: 0 };
const result = ruleEngine.evaluate(ctx);

// shield (200) executa primeiro, depois heal (100)
// ctx.defense === 10, ctx.hp === 50
// result.matched === ["shield", "heal"]
// result.counters.rulesMatched === 2

Sub-rules (Cascata Condicional)

Sub-rules permitem branching condicional dentro de uma rule. Depois que o then do pai executa, o when() de cada sub-rule é avaliado em ordem de declaração. Sub-rules podem aninhar em profundidade arbitrária (limitada por maxSubRuleDepth, default 10).

Registro

Sub-rules são registradas recursivamente como hidden actions com IDs separados por ponto:

rule:combat-heal              <- pai
rule:combat-heal.low-hp       <- sub-rule
rule:combat-heal.low-hp.crit  <- sub-rule aninhada

Group gates (sem then) não registram action — actionId = "".

Avaliação

Depois do invoke do pai (ou direto, para group gate):
  para cada sub-rule (ordem de declaração):
    +-- when(ctx) -> false -> skip | undefined -> incondicional
    +-- Invoke (se tem then) -> aborted -> propaga pra cima
    +-- Recursa (se tem sub-rules) -> aborted -> propaga pra cima

Group gates

Um group gate é uma rule com rules mas sem then. Funciona como um container puramente condicional:

{
  id: "combat-group",
  priority: 100,
  when: (ctx) => ctx.zone === "combat",
  // sem `then` — isto é um gate
  rules: [
    { id: "heal", when: (ctx) => ctx.hp < 50, then: [...] },
    { id: "buff", when: (ctx) => ctx.mp > 0, then: [...] },
  ],
}

O gate avalia when(). Se true, os filhos são avaliados. Nenhuma action é invocada para o gate em si.

Decisões de design

| Decisão | Racional | |----------|-----------| | Ordem de declaração, não priority | Sub-rules são uma cascata dentro de uma rule — a ordem importa semanticamente | | Hooks NÃO se aplicam a sub-rules | Hooks governam o loop principal, não o branching interno | | Params de middleware herdados | Os params enriquecidos pelo middleware do pai propagam as-is | | Abort propaga pra cima | Halt em sub-rule -> pai aborta -> loop principal para | | Profundidade limitada | Default 10. Exceder -> erro coletado, sub-tree skipada (sem abort) |

Exemplo: descontos por tier

ruleEngine.register([
  {
    id: "discount-engine",
    priority: 200,
    when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
    then: [{ type: "log", message: "avaliando descontos" }],
    rules: [
      {
        id: "vip-discount",
        when: (ctx) => ctx.customerTier === "vip",
        then: [
          { type: "dispatch", target: "discount", op: "set", value: 0,
            resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.2) }) },
        ],
      },
      {
        id: "gold-discount",
        when: (ctx) => ctx.customerTier === "gold",
        then: [
          { type: "dispatch", target: "discount", op: "set", value: 0,
            resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
        ],
      },
    ],
  },
]);

Hooks (Governança)

Hooks são analisados uma vez no momento da construção via analyzeSlots. Eles governam apenas a avaliação de rules. Eventos têm seus próprios hooks em @statedelta-actions/events.

Rule Hooks

ruleHooks: {
  beforeRule?: (rule: RuleDefinition, evalCtx: RuleEvaluationContext) => "skip" | "abort" | void;
  afterRule?: (rule: RuleDefinition, result: DirectiveResult, evalCtx: RuleEvaluationContext) => "abort" | void;
  onRulesComplete?: (result: RuleEvaluationResult) => void;
}

| Hook | Quando | Retornos | Comportamento de erro | |------|------|---------|----------------| | beforeRule | Antes da avaliação do when() | "skip" -> skipped, "abort" -> para o pipeline, void -> continua | Coletado em errors[], continua | | afterRule | Depois do invoke (se a rule tinha then) | "abort" -> para o pipeline, void -> continua | Fire-and-forget | | onRulesComplete | Depois de todas as rules processadas (ou abort) | void | Silenciado |

interface RuleEvaluationContext<TCtx> {
  readonly ctx: TCtx;
  readonly counters: FrameCounters;
}

Exemplo: governança por priority

const ruleEngine = createRuleEngine({
  actionEngine,
  ruleHooks: {
    beforeRule: (rule) => {
      if (rule.priority < 50) return "skip";
    },
  },
});

Exemplo: abort em erro

ruleHooks: {
  afterRule: (rule, result) => {
    if (result.errors.length > 0) return "abort";
  },
},

Middleware (Enriquecimento de Params)

Middleware roda entre o match e o invoke. Ele enriquece os params que viram o scope da action.

Modelo de composição

Baseado em delta. Cada middleware recebe os params acumulados e retorna um delta para mesclar:

params0 = {} -> mw[0](rule, ctx, params0) -> d0 -> params1 = {...params0, ...d0}
               mw[1](rule, ctx, params1) -> d1 -> params2 = {...params1, ...d1}
               ...
               invoke(actionId, paramsN)

Middleware não pode descartar o que os predecessores injetaram (só sobrescrever por chave).

Assinatura

type RuleMiddleware<TCtx> = (
  rule: RuleDefinition<TCtx>,
  ctx: TCtx,
  params: Record<string, unknown>,
) => Record<string, unknown>;

Middleware de sub-rule

Middleware não roda de novo para sub-rules. Os params enriquecidos pelo middleware do pai são passados diretamente para as invocações de sub-rule.

Tratamento de erros

Erro de middleware -> coletado em errors[], invoke skipado, a próxima rule continua.

Exemplo: middleware de auditoria customizado

const auditMiddleware: RuleMiddleware<MyCtx> = (rule, ctx, params) => ({
  $audit: {
    ruleId: rule.id,
    timestamp: Date.now(),
    userId: ctx.currentUser.id,
  },
});

const ruleEngine = createRuleEngine({
  actionEngine,
  middleware: [auditMiddleware, createInvokerMiddleware()],
});

Compilação JIT

O JIT compila o loop de iteração de rules, não diretivas individuais (o ActionEngine cuida do JIT de diretivas separadamente). Dois níveis de JIT coexistem: o ActionEngine compila a execução de diretivas, o RuleEngine compila a orquestração de rules.

O que é compilado

buildRuleExecutor gera uma função via new Function que substitui o interpretador. Mesma assinatura — o engine troca transparentemente.

Emissão condicional de código

O JS gerado contém apenas código para as features que de fato estão configuradas:

| Feature ausente | Código não emitido | |---|---| | beforeRule | Todo o bloco try/catch, checks de skip/abort | | afterRule | Todo o bloco try/catch, check de abort | | onRulesComplete | Chamada final + blocos de cleanup | | Nenhum hook | evalCtx não criado, nenhuma variável de hook | | Nenhum middleware | Variável params não declarada, nenhuma chamada de pipeline |

Tier 0 — loop minimal

Com zero hooks e zero middleware, o código gerado é um loop minimal:

for -> when(ctx) -> invoke(actionId) -> check de halt -> sub-rules -> próxima

Sem try/catch para hooks. Sem pipeline de middleware. Sem alocação de evalCtx.

Modos

const engine = createRuleEngine({ actionEngine, mode: "auto" });

| Modo | Comportamento | |------|----------| | interpret | Sempre usa o interpretador. O JIT nunca ativa. compile() é no-op. | | jit | Compila imediatamente na construção. Sem fase de interpretador. | | auto (default) | Começa com o interpretador. Promove após N chamadas (threshold). |

Threshold default: 8 chamadas de evaluate(). Configurável via autoJitThreshold.

evaluate() #1..#7  ->  interpretador
evaluate() #8      ->  compila + troca interpretador por JIT
evaluate() #9+     ->  JIT compilado

compile()

engine.compile(); // força a promoção imediata para JIT

Útil para warmup. No-op se mode === "interpret".

getter compilationMode

engine.compilationMode // "interpret" | "jit"

Reflete o estado atual — muda de "interpret" para "jit" após a promoção.


Operações em Batch

Baseado em callback (recomendado)

const result = engine.batch((eng) => {
  eng.register([rule1, rule2]);
  eng.register([rule3, rule4]);
});
// endBatch() chamado automaticamente, inclusive em throw

batch(fn) garante o cleanup: se fn lança, endBatch() ainda é chamado para não deixar o engine num estado inconsistente.

Manual

engine.beginBatch();
engine.register([rule1, rule2]);
engine.register([rule3, rule4]);
const result = engine.endBatch();
  • Delega beginBatch()/endBatch() ao ActionEngine
  • Acumula os results de registro através de múltiplas chamadas de register()
  • Mescla tudo no endBatch()
  • Suporta aninhamento: endBatch() interno retorna vazio, o externo mescla tudo

Suporte Async

Detecção (per-rule transitivo)

const isAsync = ruleHookAnalysis.hasAnyAsync || hasAnyAsyncRuleTransitive;

Refrescado em register() / unregister() / endBatch() consultando o mini-graph interno do ActionEngine (ADR-026 do actions). Cada StoredRule cacheia isAsync/isInteractive per-rule (refletindo actionEngine.isActionAsync(rule.actionId)).

Diferença importante: antes era actionEngine.isAsync (global). Agora é per-rule transitivo. Engine híbrido (handler async + handler sync) com rules 100% sync na sub-árvore permite evaluate() regular — só rules transitivamente async forçam evaluateAsync().

// Engine híbrido — handler async existe, mas a action `log` (usada em syncRule) é sync
const engine = createActionEngine({
  handlers: { log, fetchAsync: { async: true, async execute() {...} } },
});
const rules = createRuleEngine({ actionEngine: engine });

rules.register([
  { id: "syncRule", priority: 1, when: () => true, then: [{ type: "log", message: "x" }] },
]);

engine.isAsync;             // true (engine global async — fetchAsync existe)
rules.isAsync;              // false (a rule específica é sync transitivamente)
rules.evaluate(ctx);        // ✅ funciona — não força evaluateAsync

Uso

// Sync (lança se isAsync)
const result = engine.evaluate(ctx);

// Async (sempre funciona)
const result = await engine.evaluateAsync(ctx);

Chamar evaluate() quando isAsync === true lança um erro — é preciso usar evaluateAsync(). Isso é um guard contra descartar promises por acidente.

Hooks async

const ruleEngine = createRuleEngine({
  actionEngine,
  ruleHooks: {
    beforeRule: async (rule, evalCtx) => {
      const allowed = await checkPermission(rule.id);
      if (!allowed) return "skip";
    },
    afterRule: async (rule, result) => {
      await logToExternalService(rule.id, result);
    },
  },
});


// Precisa usar evaluateAsync
const result = await ruleEngine.evaluateAsync(ctx);

Modo Interactive

Permite pausar a avaliação de rules e aguardar input externo via generators — espelhamento do modo interactive do ActionEngine. Ortogonal a sync/async.

Habilitação: o ActionEngine precisa estar em modo interactive (createActionEngine({ ..., interactive: {} })). O Rules detecta automaticamente quando alguma rule registrada tem ação transitivamente interactive.

Detecção per-rule

StoredRule.isInteractive cacheia o resultado de engine.isActionInteractive(rule.actionId). Refrescado em register/unregister/endBatch.

rules.isInteractive;              // true se qualquer rule é interactive transitivamente
rules.evaluate(ctx);              // lança "use evaluateInteractive"
rules.evaluateAsync(ctx);         // idem
rules.evaluateInteractive(ctx);   // ✅ retorna iterator

evaluateInteractive

Retorna InteractiveRuleSession (sync) ou AsyncInteractiveRuleSession (async, quando rules.isAsync). Drenagem via next(value):

Handlers são primitivas Lego (ADR-028 do actions). Use input (genérico) com payload domain-specific. Nunca crie askUser, confirmEmail, etc — viola o princípio canonical.

const engine = createActionEngine({
  handlers: {
    // Primitiva canonical: yield + schema + retry interno
    input: { interactive: true, *execute(d) {
      const ans = yield { kind: "input", payload: d.payload, schema: d.schema };
      return { ok: true, data: ans };
    } },
    log: { execute: (d, f) => { f.ctx.out.push(d.message); return { ok: true }; } },
  },
  interactive: {},
});

const rules = createRuleEngine({ actionEngine: engine });
rules.register([
  { id: "wizard", priority: 1, when: () => true, then: [
    { type: "log", message: "before" },
    { type: "pause", message: "Confirmar?", as: "ack" },
    { type: "input", payload: { kind: "text", label: "Nome" }, as: "name" },
    { type: "log", resolve: (_c, s) => ({ message: `${s.name} (${s.ack})` }) },
  ] },
]);

const ctx = { out: [] };
const session = rules.evaluateInteractive(ctx);

session.next();              // → { source: "pause", payload: { message: "Confirmar?" } }
                             //   ctx.out: ["before"]
session.next("yes");         // → { kind: "input", payload: { kind: "text", label: "Nome" } }
session.next("Anderson");    // → { done: true, value: RuleEvaluationResult }
                             //   matched: ["wizard"]
                             //   ctx.out: ["before", "Anderson (yes)"]

Mistura sync + interactive (ordem de priority)

Rules sync e interactive coexistem na mesma avaliação, respeitando a priority. Rules sync rodam direto (sem yield); rules interactive pausam.

rules.register([
  { id: "first", priority: 10, when: () => true, then: [{ type: "log", message: "first" }] },
  { id: "wizard", priority: 5, when: () => true, then: [
    { type: "pause", message: "?" },
  ] },
  { id: "last", priority: 1, when: () => true, then: [{ type: "log", message: "last" }] },
]);

const session = rules.evaluateInteractive(ctx);
session.next();        // first roda direto, wizard pausa
                       // ctx.out: ["first"]
session.next("ok");    // wizard completa, last roda
                       // ctx.out: ["first", "last"]

Async generator (engine async + rule interactive)

Quando alguma rule é async transitivamente E alguma é interactive, evaluateInteractive retorna AsyncInteractiveRuleSession. Drenagem via await session.next():

rules.register([
  { id: "loadData", priority: 10, when: () => true, then: [
    { type: "fetchAsync", as: "v" },
    { type: "log", resolve: (_c, s) => ({ message: `loaded ${s.v}` }) },
  ] },
  { id: "getName", priority: 5, when: () => true, then: [
    { type: "input", payload: { kind: "text", label: "Nome" }, as: "name" },
  ] },
]);

const session = rules.evaluateInteractive(ctx);  // AsyncIterator

await session.next();          // loadData rodou (fetch async), getName yieldou
await session.next("Anderson"); // matched: ["loadData", "getName"]

Compilação do JIT generator

Em mode: "jit", evaluateInteractive usa JIT generator compilado (Fase R6 — buildInteractiveRuleExecutor). 4 wrappers possíveis:

| isAsync | isInteractive | Wrapper | |-----------|-----------------|---------| | false | false | function () (path original — evaluate) | | true | false | async function () (evaluateAsync) | | false | true | function* () (evaluateInteractive sync) | | true | true | async function* () (evaluateInteractive async) |

Branch emitido per-rule no loop do JIT:

if (stored.isInteractive) {
  ir = yield* ae.invokeInteractive(stored.actionId, params);
} else if (stored.isAsync) {
  ir = await ae.invokeAsync(stored.actionId, params);
} else {
  ir = ae.invoke(stored.actionId, params);
}

Decisão per-rule em runtime, baseada nas flags cached em StoredRule.isAsync/isInteractive. Em mode: "interpret" ou "auto" (antes do threshold), usa o interpreter generator (mesmo comportamento, sem a performance do unrolled).


Tratamento de Erros

O engine nunca lança durante a avaliação. Todos os erros de runtime são coletados:

| Origem do erro | Comportamento | |-------------|----------| | when() lança | Erro coletado, rule tratada como notMatched, continua | | beforeRule lança | Erro coletado, continua (tratado como void) | | afterRule lança | Fire-and-forget, continua | | onRulesComplete lança | Silenciado | | Middleware lança | Erro coletado, invoke skipado, próxima rule | | Erros de invoke do ActionEngine | Erros de diretiva acumulados nos counters | | Id de hidden action ausente no momento do invoke | Pipeline aborta com abortedBy: "action-not-found", success: false | | when() de sub-rule lança | Erro coletado, sub-rule skipada | | maxSubRuleDepth excedido | Erro coletado, sub-tree skipada (sem abort) |

register() segue regras diferentes — ver Registro. Erros estruturais em boot (handler ausente) lançam fail-fast.

Inspecionando erros

const result = engine.evaluate(ctx);

for (const err of result.errors) {
  console.log(`Rule index ${err.ruleIndex}: ${err.error}`);
}

// O consumer decide o que os erros significam:
if (result.errors.length > 0) {
  // trata os erros
}

HALT_HANDLER

Um handler built-in que sinaliza ao ActionEngine para abortar a execução de diretivas. O interpretador do RuleEngine checa abortedBy === "halt" como uma parada controlada.

import { HALT_HANDLER } from "@statedelta-actions/rules";

const actionEngine = createActionEngine({
  handlers: {
    ...myHandlers,
    halt: HALT_HANDLER,
  },
});

Uso em diretivas:

{
  id: "guard",
  priority: 500,
  when: (ctx) => ctx.hp <= 0,
  then: [
    { type: "dispatch", target: "status", op: "set", value: "dead" },
    { type: "halt" }, // para todas as rules restantes
  ],
}

Quando o halt dispara:

  • result.aborted === true
  • result.abortedBy === "halt"
  • result.success === true (halt é uma parada controlada, não um erro)
  • As rules restantes não são avaliadas

createInvokerMiddleware

Middleware built-in, opt-in, que injeta metadados $invoker no scope da action:

import { createInvokerMiddleware } from "@statedelta-actions/rules";

const ruleEngine = createRuleEngine({
  actionEngine,
  middleware: [createInvokerMiddleware()],
});

O que ele injeta

{
  $invoker: {
    ruleId: "combat-heal",
    priority: 100,
    tags: ["combat"],
  }
}

Propagação de scope

$invoker propaga para todas as sub-actions no ActionEngine via scope de prototype chain. Se o seu handler invoca uma action filha, o frame.scope.$invoker da filha herda do pai.

Caso de uso: trilha de auditoria

const auditHandler = {
  analyze: () => ({ capabilities: [], dependencies: [] }),
  execute: (_d, frame) => {
    const invoker = frame.scope.$invoker;
    auditLog.push({
      ruleId: invoker.ruleId,
      priority: invoker.priority,
      timestamp: Date.now(),
    });
    return { ok: true };
  },
};

Referência Completa da API

IRuleEngine<TCtx>

interface IRuleEngine<TCtx> {
  // Registro
  register(rules: readonly RuleDefinition<TCtx>[]): RuleRegisterResult;
  unregister(id: string): boolean;

  // Avaliação de trigger
  evaluate(ctx: TCtx): RuleEvaluationResult;
  evaluateAsync(ctx: TCtx): Promise<RuleEvaluationResult>;

  // Batch
  beginBatch(): void;
  endBatch(): RuleRegisterResult;
  batch(fn: (engine: IRuleEngine<TCtx>) => void): RuleRegisterResult;

  // JIT
  compile(): void;

  // Introspecção
  has(id: string): boolean;              // aceita IDs qualificados para sub-rules
  readonly size: number;                  // contagem de rules top-level

  // Accessors
  readonly actionEngine: IActionEngine<TCtx>;
  readonly isAsync: boolean;
  readonly compilationMode: "interpret" | "jit";
}

RuleEngineConfig<TCtx>

interface RuleEngineConfig<TCtx> {
  actionEngine: IActionEngine<TCtx>;              // obrigatório
  middleware?: readonly RuleMiddleware<TCtx>[];     // opcional, default []
  ruleHooks?: RuleHooks<TCtx>;                    // opcional
  maxSubRuleDepth?: number;                        // opcional, default 10
  mode?: "interpret" | "jit" | "auto";             // opcional, default "auto"
  autoJitThreshold?: number;                       // opcional, default 8
}

FrameCounters

interface FrameCounters {
  rulesEvaluated: number;
  rulesMatched: number;
  rulesSkipped: number;
  directivesApplied: number;
  directivesSkipped: number;
  subRunsCreated: number;
  errors: number;
}

Caso de Uso: Tick de Combate RPG

Um tick de jogo que avalia rules de combate com sub-rules e halt.

interface GameCtx {
  hp: number;
  maxHp: number;
  mp: number;
  zone: string;
  effects: string[];
  log: string[];
}

// --- Setup do ActionEngine ---

const actionEngine = createActionEngine<GameCtx>({
  handlers: {
    dispatch: dispatchHandler,
    emit: emitHandler,
    log: logHandler,
    halt: HALT_HANDLER,
  },
});

// --- Setup do RuleEngine ---

const ruleEngine = createRuleEngine<GameCtx>({
  actionEngine,
  middleware: [createInvokerMiddleware()],
  ruleHooks: {
    beforeRule: (rule) => {
      if (rule.priority < 50) return "skip";
    },
  },
});

// --- Rules ---

ruleEngine.register([
  // Check de morte de alta priority — para tudo
  {
    id: "death-check",
    priority: 500,
    when: (ctx) => ctx.hp <= 0,
    then: [
      { type: "log", message: "morto — halting" },
      { type: "halt" },
    ],
  },

  // Rules de zona de combate com sub-rules
  {
    id: "combat-zone",
    priority: 100,
    when: (ctx) => ctx.zone === "combat",
    then: [
      { type: "dispatch", target: "hp", op: "dec", value: 15 },
      { type: "emit", event: "damage-tick" },
    ],
    rules: [
      {
        id: "low-hp-heal",
        when: (ctx) => ctx.hp < 50,
        then: [
          { type: "dispatch", target: "hp", op: "inc", value: 5 },
        ],
      },
    ],
  },

  // Regen de MP
  {
    id: "mp-regen",
    priority: 50,
    when: (ctx) => ctx.mp < 100,
    then: [
      { type: "dispatch", target: "mp", op: "inc", value: 3 },
    ],
  },
]);

// --- Tick ---

const ctx: GameCtx = { hp: 40, maxHp: 100, mp: 30, zone: "combat", effects: [], log: [] };

const result = ruleEngine.evaluate(ctx);
// combat-zone dispara: hp 40->25, emite "damage-tick"
// low-hp-heal dispara: hp 25->30
// mp-regen dispara: mp 30->33

Caso de Uso: Pipeline de Pedido E-commerce

Um pipeline de processamento de pedido com validação, descontos por tier e detecção de fraude.

interface OrderCtx {
  orderId: string;
  status: string;
  items: Array<{ sku: string; qty: number; price: number }>;
  subtotal: number;
  discount: number;
  total: number;
  customerTier: string;
  coupon: string | null;
  paymentMethod: string;
  flags: string[];
  log: string[];
}

ruleEngine.register([
  // 1. Detecção de fraude — priority mais alta, para o pipeline
  {
    id: "fraud-check",
    priority: 500,
    when: (ctx) => ctx.subtotal > 1000 && ctx.customerTier === "bronze",
    then: [
      { type: "dispatch", target: "flags", op: "push", value: "fraud-review" },
      { type: "dispatch", target: "status", op: "set", value: "held" },
      { type: "halt" },
    ],
  },

  // 2. Calcula o subtotal
  {
    id: "calc-subtotal",
    priority: 400,
    when: (ctx) => ctx.status === "pending",
    then: [
      {
        type: "dispatch", target: "subtotal", op: "set", value: 0,
        resolve: (ctx) => ({
          value: ctx.items.reduce((sum, i) => sum + i.qty * i.price, 0),
        }),
      },
      { type: "dispatch", target: "status", op: "set", value: "validated" },
    ],
  },

  // 3. Descontos por tier via sub-rules
  {
    id: "discount-engine",
    priority: 300,
    when: (ctx) => ctx.status === "validated" && ctx.discount === 0,
    then: [{ type: "log", message: "avaliando descontos" }],
    rules: [
      {
        id: "gold-discount",
        when: (ctx) => ctx.customerTier === "gold",
        then: [
          { type: "dispatch", target: "discount", op: "set", value: 0,
            resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.15) }) },
        ],
      },
      {
        id: "silver-discount",
        when: (ctx) => ctx.customerTier === "silver",
        then: [
          { type: "dispatch", target: "discount", op: "set", value: 0,
            resolve: (ctx) => ({ value: Math.round(ctx.subtotal * 0.1) }) },
        ],
      },
    ],
  },

  // 4. Pagamento
  {
    id: "process-payment",
    priority: 200,
    when: (ctx) => ctx.status === "validated",
    then: [
      { type: "dispatch", target: "total", op: "set", value: 0,
        resolve: (ctx) => ({ value: ctx.subtotal - ctx.discount }) },
      { type: "dispatch", target: "status", op: "set", value: "paid" },
      { type: "emit", event: "payment-processed" },
    ],
  },
]);

Caso de Uso: Workflow de Aprovação de Deploy

Um pipeline de CI/CD com submissão, atribuição de reviewers, gates de aprovação e seleção de estratégia de deploy via sub-rules.

interface WorkflowCtx {
  id: string;
  status: string;
  type: string;
  environment: string;
  author: string;
  reviewers: string[];
  approvals: string[];
  rejections: string[];
  requiredApprovals: number;
  riskScore: number;
  flags: string[];
  metadata: Record<string, unknown>;
  log: string[];
}

ruleEngine.register([
  // 1. Check de rejeição — priority mais alta, para tudo
  {
    id: "check-rejections",
    priority: 500,
    when: (ctx) => ctx.status === "in-review" && ctx.rejections.length > 0,
    then: [
      { type: "dispatch", target: "status", op: "set", value: "rejected" },
      { type: "halt" },
    ],
  },

  // 2. Submissão com atribuição de reviewers via sub-rules
  {
    id: "submit",
    priority: 400,
    when: (ctx) => ctx.status === "draft",
    then: [
      { type: "dispatch", target: "status", op: "set", value: "submitted" },
    ],
    rules: [
      {
        id: "assign-feature-reviewers",
        when: (ctx) => ctx.type === "feature",
        then: [
          { type: "dispatch", target: "reviewers", op: "push", value: "tech-lead" },
          { type: "dispatch", target: "reviewers", op: "push", value: "senior-dev" },
        ],
      },
      {
        id: "assign-infra-reviewers",
        when: (ctx) => ctx.type === "infra",
        then: [
          { type: "dispatch", target: "reviewers", op: "push", value: "devops-lead" },
          { type: "dispatch", target: "reviewers", op: "push", value: "sre" },
        ],
      },
    ],
  },

  // 3. Estratégia de deploy por environment + risco via sub-rules
  {
    id: "deploy",
    priority: 100,
    when: (ctx) => ctx.status === "approved",
    then: [{ type: "dispatch", target: "status", op: "set", value: "deploying" }],
    rules: [
      {
        id: "deploy-production",
        when: (ctx) => ctx.environment === "production",
        then: [{ type: "log", message: "deploy de produção" }],
        rules: [
          {
            id: "canary-deploy",
            when: (ctx) => ctx.riskScore > 50,
            then: [
              { type: "dispatch", target: "flags", op: "push", value: "canary" },
            ],
          },
          {
            id: "blue-green-deploy",
            when: (ctx) => ctx.riskScore <= 50,
            then: [
              { type: "dispatch", target: "flags", op: "push", value: "blue-green" },
            ],
          },
        ],
      },
    ],
  },
]);

Espectro de Performance

Tier 0 (sem hooks, sem middleware)    Full (hooks + middleware + sub-rules)
---------------------------------------------------------------------
Zero alocações para hooks             evalCtx criado por evaluate()
Sem try/catch                         try/catch por hook
Sem variável params                   params alocados + pipeline de middleware
Loop minimal                          Pipeline completo de governança

O compilador JIT emite apenas os code paths que estão configurados. Tier 0 é o caso comum para cenários de alta performance (game loops, tempo real).


Notas de Design Interno

Para arquitetura interna detalhada, veja docs/ARCHITECTURE.md.

Decisões-chave

  • Rules são hidden actions com prefixo rule:. Sub-rules usam separador ..
  • Sub-rules são sempre interpretadas — o JIT compila apenas o loop principal.
  • Ponto de saída único no interpretador — onRulesComplete chamado exatamente uma vez.
  • Closure counter + swap por noop para auto-promote — zero overhead pós-JIT.
  • Object.assign in-place para middleware — 1 alocação vs N+1.
  • Imutabilidade por convenção — sem Object.freeze() (custo mensurável em hot paths).
  • Eventos são um package separado@statedelta-actions/events. O RuleEngine não conhece eventos.