@statedelta-actions/rules
v0.7.0
Published
Rule evaluation engine with JIT-optimized sequential evaluation and sub-rules
Downloads
1,221
Maintainers
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
- Filosofia
- Visão Geral da Arquitetura
- Instalação & Setup
- Início Rápido
- Definição de Rule
- Registro
- Avaliação de Triggers
- Sub-rules (Cascata Condicional)
- Hooks (Governança)
- Middleware (Enriquecimento de Params)
- Compilação JIT
- Operações em Batch
- Suporte Async
- Modo Interactive
- Tratamento de Erros
- HALT_HANDLER
- createInvokerMiddleware
- Referência Completa da API
- Caso de Uso: Tick de Combate RPG
- Caso de Uso: Pipeline de Pedido E-commerce
- Caso de Uso: Workflow de Aprovação de Deploy
- Espectro de Performance
- 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 -> repeatModelo 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/AsyncExports 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:
- Priority descendente — maior priority avalia primeiro.
- FIFO entre prioridades iguais — empate resolve por ordem de registro (a rule registrada antes avalia antes). Inserção via binary insertion estável.
- Fonte única —
evaluate(),evaluateAsync()eevaluateInteractive()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() percorreValidaçã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.
- 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 emerrors[]. Ainda sem indexação local. - Delegar ao ActionEngine —
actionEngine.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 aerrors[]. - Indexar — só é alcançado quando a fase 2 retorna. Insere cada rule em
_ruleRegistrye_rules(ordenado por priority desc), refresca as flagsisAsync/isInteractiveper-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 removidoRemove 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 resultRuleEvaluationResult
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 === 2Sub-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 aninhadaGroup 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 cimaGroup 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óximaSem 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 compiladocompile()
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 throwbatch(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 evaluateAsyncUso
// 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 iteratorevaluateInteractive
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 crieaskUser,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 === trueresult.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->33Caso 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çaO 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 —
onRulesCompletechamado exatamente uma vez. - Closure counter + swap por noop para auto-promote — zero overhead pós-JIT.
Object.assignin-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.
