@statedelta-actions/actions
v0.13.0
Published
Directive execution engine with JIT compilation and BailHook interception
Maintainers
Readme
@statedelta-actions/actions
Motor de execução de diretivas com validação em register-time e compilação JIT.
Defina ações como objetos JS declarativos. Registre. O engine valida estrutura, compila executores otimizados. Em runtime, invoke é O(1) lookup + execução — zero validação, zero análise.
Filosofia
Engine = V8. Runtime puro.
O engine é um executor. Não analisa dependências, não computa grafos, não valida contratos. Registro valida estrutura e compila. Invocação executa. Análise estática (grafo, capabilities, ciclos, composition control) é responsabilidade do @statedelta-actions/analyzer — uma camada externa opcional.
Ações são declarativas. Uma action é um ID mais uma lista ordenada de diretivas — objetos JS declarativos despachados por um campo de tipo (type por padrão). O engine não sabe o que "state", "emit" ou "action" significam. Handlers dão significado às diretivas. O engine orquestra a execução.
Handlers têm fases. Um handler não é só um executor. Ele pode validar estrutura da diretiva em register-time, contribuir código JIT, e executar em runtime. Cada fase é opcional — uma função simples funciona como handler (compat V1). Uma definição completa desbloqueia todo o pipeline. A fase analyze existe na interface mas é consumida pelo ActionAnalyzer, não pelo engine.
Sem sugar no runtime. Diretivas devem ser passadas em forma canônica — toda diretiva tem campo type. Sugar forms (shorthands de autoria) são concern do compilador JSON DSL, que normaliza antes de entregar pro engine. O engine não interpreta nem converte sugar.
Modo interactive opcional. Actions podem pausar entre diretivas e aguardar input externo via generators (sync ou async). Implementado de forma ortogonal ao modo async — a granularidade é per-action transitiva via mini-graph interno. Ver Modo Interactive abaixo.
Instalação
pnpm add @statedelta-actions/actionsInício Rápido
import { createActionEngine } from "@statedelta-actions/actions";
// 1. Defina handlers — dão significado aos tipos de diretiva
const handlers = {
state: {
execute: (directive, frame) => {
const { target, value } = directive;
frame.ctx.state[target] = value;
return { ok: true, data: value };
},
},
emit: {
execute: (directive, frame) => {
frame.ctx.events.push(directive.event);
return { ok: true };
},
},
action: {
execute: (directive, frame, engine) => {
const result = engine.invoke(directive.id, directive.params, frame);
return { ok: result.success, data: result.data };
},
},
};
// 2. Crie o engine
const engine = createActionEngine({ handlers });
// 3. Registre ações
const result = engine.register([
{
id: "heal",
directives: [
{ type: "state", target: "hp", value: 100 },
{ type: "emit", event: "healed" },
],
},
{
id: "combat",
directives: [
{ type: "action", id: "heal" },
{ type: "emit", event: "combat:done" },
],
},
]);
// result.registered → ["heal", "combat"]
// result.errors → []
// result.warnings → []
// 4. Invoque com contexto
const ctx = { state: {}, events: [] };
const r = engine.invoke("heal", undefined, ctx);
// r.success → true
// r.appliedCount → 2Handlers
Um handler processa diretivas de um tipo específico. Dois formatos:
V1 — Função simples
const handlers = {
log: (directive, frame, engine) => {
console.log(directive.message);
return { ok: true };
},
};Compatível com versões anteriores. Sem análise em register-time. Emite warning NO_ANALYZE.
V2 — Definição completa
const handlers = {
state: {
// Register-time: valida estrutura da diretiva
validate(directive) {
if (!directive.target) return { valid: false, error: "missing target" };
},
// Runtime: executa a diretiva
execute(directive, frame, engine) {
frame.ctx.state[directive.target] = directive.value;
return { ok: true, data: directive.value };
},
// Usado pelo ActionAnalyzer (não pelo engine):
// analyze(directive) {
// return { capabilities: ["write"], dependencies: [] };
// },
},
};| Fase | Quando | Propósito |
|------|--------|-----------|
| validate | Register | Validação estrutural (rejeita diretivas malformadas) |
| execute | Runtime | Floor — processa a diretiva e retorna resultado |
| executeAsync | Runtime | Variante opcional — usada em actions transitivamente async (ver Handler Multi-Variant) |
| executeInteractive | Runtime | Variante opcional — usada em actions transitivamente interactive |
| analyze | — | Consumido pelo ActionAnalyzer externo, não pelo engine |
| async (flag) | Construct | Marca handler como assíncrono (opt-in explícito) |
| interactive (flag) | Construct | Marca handler como interactive (opt-in explícito) |
| subDirectives | Register | Declara campos com sub-arrays que entram no grafo |
Sub-Directives — handlers com filhos no grafo
Handlers customizados podem ter sub-arrays de diretivas que participam do grafo de análise (deps, async/interactive transitivo, denied-scan, capabilities). Em vez de o engine hardcodear nomes como then/else/catch, o handler declara explicitamente quais campos da sua diretiva são sub-blocos:
const handlers = {
simulate: {
subDirectives: {
directives: { required: true },
},
execute(d, frame, engine) {
beginTransaction(frame.ctx);
const r = engine.runDirectives(d.directives, frame);
if (!r.success) rollback(frame.ctx);
else commit(frame.ctx);
return { ok: r.success, data: r.data };
},
},
try_: {
subDirectives: {
body: { required: true },
catch: { required: false },
finally: { required: false },
},
execute(d, frame, engine) {
const r = engine.runDirectives(d.body, frame);
if (!r.success && d.catch) {
frame.scope.$exception = r.errors[0]?.message;
engine.runDirectives(d.catch, frame);
}
if (d.finally) engine.runDirectives(d.finally, frame);
return { ok: r.success, data: r.data };
},
},
};Uso na action:
engine.register([{
id: "checkout",
directives: [
{
type: "simulate",
directives: [ // ← entra no grafo
{ type: "action", id: "charge" }, // edge: checkout → charge
{ type: "fetchUserAsync", id: 1 }, // checkout vira async transitivo
],
},
],
}]);Comportamento estrutural:
| Campo | Default | Efeito |
|-------|---------|--------|
| required: true | — | Campo ausente ou não-array → throw em register-time |
| required: false | ✓ | Campo ausente é tratado como [] silenciosamente |
| graph: true | ✓ | Sub-array participa de mini-graph e walks do analyzer |
| graph: false | — | Sub-array é validado estruturalmente mas NÃO conta como path de execução (ex: campos de metadata/preview) |
Importante:
- O descritor é puramente estrutural — não interfere na execução. O handler
executedecide se, quando e como rodar os sub-blocos viaengine.runDirectives/runDirectivesAsync. ifecatchpermanecem hardcoded — primitivas do engine com otimizações específicas. O novo descritor é aditivo.- Sub-directives são propagadas transitivamente: action que usa
simulatecujo sub-bloco invoca handler async automaticamente vira async —engine.isActionAsync("checkout")retornatrue.
Inspection metadata
Tanto o HandlerDefinition quanto cada SubDirectiveFieldConfig aceitam campos de inspeção opcionais — zero custo runtime, expostos via engine.handlerDefinitions accessor pra tooling, DSL JSON externo, IDE hover docs, documentação auto-gerada:
const try_: HandlerDefinition = {
description: "Executa body; em falha executa catch; finally roda sempre.",
tags: ["control-flow", "error-handling"],
since: "0.5.0",
// deprecated: "use 'tryAsync' since v0.6",
// aliasOf: "tryAsync",
subDirectives: {
body: {
required: true,
purpose: "body",
description: "Diretivas tentadas. Falha dispara catch.",
examples: [[{ type: "action", id: "charge" }]],
tags: ["execution"],
},
catch: {
required: false,
purpose: "catch",
description: "Diretivas executadas em falha. scope.$exception disponível.",
},
finally: {
required: false,
purpose: "finalizer",
},
},
execute(d, frame, engine) { /* ... */ },
};Campos suportados:
| Campo | Nível | Tipo | Uso típico |
|-------|-------|------|-----------|
| description | handler + sub-campo | string | Hover docs em IDE, descoberta de esquema por DSL |
| tags | handler + sub-campo | string[] | Categorização cruzada, filtros |
| purpose | sub-campo | string | Label semântico ("body", "catch", "branch", "finalizer", "metadata", custom) |
| examples | sub-campo | Directive[][] | DSL/IDE autocomplete, docs auto-geradas |
| deprecated | handler | boolean \| string | Marca obsolescência, mensagem de migração |
| since | handler | string | Versionamento (semver ou livre) |
| aliasOf | handler | string | Declara alias semântico de outro handler |
Handlers Async
Handlers podem retornar Promise<ApplyResult>. O engine detecta async de duas formas:
const handlers = {
// 1. Auto-detect via `async function` — pega quando execute é declarado async.
fetchUser: {
async execute(directive, frame) {
const user = await api.get(`/users/${directive.id}`);
return { ok: true, data: user };
},
},
// 2. Flag explícita — para wrappers que retornam Promise sem ser `async function`.
delegateAsync: {
async: true,
execute: (d, frame, engine) => engine.invokeAsync("inner", undefined, frame),
},
};Engine híbrido. O engine pode misturar handlers sync e async no mesmo registry. Se qualquer handler for async (ou qualquer hook for async), o engine inteiro vira async — invoke() lança e você passa a usar invokeAsync(). Mas o JIT per-action decide sync/async por action: actions que só usam handlers sync compilam wrapper sync, sem await. FPS/games com 100% handlers sync pagam zero overhead de async; ETL/business com handlers async awaita só onde precisa.
const engine = createActionEngine({
handlers: {
update: { execute: (d, f) => { /* sync */ return { ok: true }; } },
fetchDB: { async execute(d, f) { return { ok: true, data: await db.query(...) }; } },
},
});
engine.isAsync; // true (fetchDB é async)
engine.invoke("anything"); // throws — use invokeAsync
// JIT compila:
// action que usa só `update` → wrapper sync, zero await
// action que usa `fetchDB` → wrapper async, await no handlerHandler Multi-Variant
Para wrapper handlers (simulate, try_, transaction) que delegam execução do sub-bloco, o modo correto depende do conteúdo concreto do sub-bloco, não de algo intrínseco ao handler. Em JS, uma função tem shape fixo na definição (function / async function / function* / async function*), então um único execute não consegue ser polimórfico.
Solução: o handler declara 3 variantes opcionais. O engine escolhe a certa em compile-time da action via mini-graph:
const simulate = {
subDirectives: { directives: { required: true } },
// Floor — usado em actions sync transitivas
execute(d, frame, engine) {
beginTx(frame.ctx);
const r = engine.runDirectives(d.directives, frame);
if (!r.success) rollback(frame.ctx); else commit(frame.ctx);
return { ok: r.success, data: r.data };
},
// Opcional — usado em actions async transitivas
async executeAsync(d, frame, engine) {
beginTx(frame.ctx);
const r = await engine.runDirectivesAsync(d.directives, frame);
if (!r.success) rollback(frame.ctx); else commit(frame.ctx);
return { ok: r.success, data: r.data };
},
// Opcional — usado em actions interactive transitivas (propaga pauses)
async *executeInteractive(d, frame, engine) {
beginTx(frame.ctx);
const session = engine.runDirectivesInteractive(d.directives, frame);
const r = yield* session;
if (!r.success) rollback(frame.ctx); else commit(frame.ctx);
return { ok: r.success, data: r.data };
},
};Regras de seleção (compile-time da action, via mini-graph):
| Action transitivamente | Variante chamada |
|------------------------|------------------|
| sync | execute (floor) |
| async | executeAsync se presente, senão execute (engine awaita — await em sync é no-op) |
| interactive | executeInteractive se presente, senão execute (deve ser generator ou flag interactive: true) |
Custo runtime: zero. A escolha de variante acontece 1x no compile da action. JIT consome $.h[type] direto — código gerado é idêntico ao single-mode. Benchmark confirma: multi-variant vs single-mode = +1.23% (dentro do ruído).
Convenção: executeInteractive é sempre async function* (async generator) — cobre os 2 modos interactive (sync+interactive e async+interactive) com 1 implementação. await em valor sync é no-op (V8 otimiza).
Capability vs Mode. Variantes opcionais (executeAsync, executeInteractive) são capabilities — declaram "o handler pode ser chamado nesse modo se necessário". NÃO entram no asyncHandlerSet/interactiveHandlerSet do mini-graph. O modo do handler em si é determinado pelo floor execute + flags. Isso preserva o princípio "actions sync não pagam overhead async/interactive".
Validação register-time. Se uma action é transitivamente interactive (sub-bloco contém pause, handler interactive, ou call pra action interactive) e o wrapper handler usado não tem suporte interactive (executeInteractive ausente + execute não é generator + flag interactive: true não set), o register lança erro MISSING_INTERACTIVE_VARIANT e remove a action do registry (rollback). Leaf handlers (sem subDirectives) não precisam de variante interactive — JIT chama execute regular mesmo em action interactive.
Quem precisa de variantes? Wrapper handlers que delegam o sub-bloco. Leaf handlers (state, emit, log, fetchUser) continuam single-mode normal.
Inversão do "function coloring problem". Em JS, uma função declarada async contamina toda a cadeia de callers. Aqui o engine resolve a contaminação automaticamente — o customer escreve simulate 1x com variantes, e o engine decide qual chamar baseado em quem usa. Mesmo nome, múltiplos modos.
Ver ADR-030 para detalhes da decisão.
Handler action canônico — createActionHandler()
O tipo { type: "action", id } (composição de actions) não tem handler built-in — o engine não hardcoda significado de handler (ADR-028). Mas o handler action correto não é engine.invoke(...) simples: ele precisa ramificar pelo modo transitivo do target (isActionInteractive → yield* / isActionAsync → invokeAsync / else invoke — ADR-024). Reimplementar isso à mão é repetitivo e fácil de errar (a parte interactive quebra silenciosamente).
O actions exporta a forma canônica desse protocolo — opt-in, multi-variant, zero overhead:
import { createActionEngine, createActionHandler } from "@statedelta-actions/actions";
const engine = createActionEngine({
handlers: {
action: createActionHandler(), // composição canônica
state, emit, log,
},
});Cobre toda a matriz container × target (sync/async/interactive) — o resolver per-action (ADR-030) escolhe a variante pelo modo transitivo do container; o executeInteractive delega via engine.invokeInteractive, que resolve o modo do target. Campos configuráveis:
createActionHandler({ idField: "ref", paramsField: "args" }); // default: "id"/"params"Peça completa, incluindo analyze. Um handler é execute (+ variantes) + analyze. O factory entrega o analyze canônico por default — { capabilities: ["invoke"], dependencies: [<id alvo>] } — porque isso é identidade do handler, não concern do consumer (entregar só execute faria o consumer recablear analyze, com erro silencioso = buraco no grafo do Analyzer). Custo zero quando o analyzer está off (property inerte). Override: createActionHandler({ analyze: customFn }) ou { analyze: false } (invisível ao grafo).
Precedente: @statedelta-actions/rules exporta HALT_HANDLER pelo mesmo princípio (a lib dona do protocolo fornece a peça canônica completa). Ver ADR-034.
Ações
Uma ação é um ID mais diretivas:
engine.register([{
id: "checkout",
directives: [
{ type: "validate", schema: "cart" },
{ type: "state", target: "status", value: "processing" },
{ type: "action", id: "checkout/charge" },
{ type: "emit", event: "checkout:complete" },
],
}]);Categorias de Diretiva
Três categorias. Toda diretiva tem campo type (forma canônica). O engine opera exclusivamente sobre forma canônica.
Binding — escrita no scope de execução:
{ type: "const", name: "tax", value: 0.1 }
{ type: "let", name: "total", value: "$", resolve: (ctx, scope) => ({ value: scope.subtotal * 1.1 }) }Control — saída antecipada e ramificação:
{ type: "return", value: "done" } // success: true, data: "done"
{ type: "throw", message: "saldo insuficiente" } // success: false
{ type: "if", cond: (ctx, scope) => scope.hp > 0,
then: [...], else: [...] } // ramificação inlineHandler — dispatch pro handler registrado:
{ type: "state", target: "hp", value: 100, as: "prev" }
{ type: "action", id: "heal", catch: [{ type: "emit", event: "heal:failed" }] }O interpreter e JIT operam sobre formato uniforme — um único dispatch via type field, sem branching de categorias.
Os nomes const, let, return, throw, if, pause são tipos reservados — o engine rejeita handlers com esses nomes.
if (then/else)
cond aceita função (ctx, scope) => boolean ou boolean literal. Branches executam no mesmo scope da action — const/let em branch ficam visíveis depois. return/throw/halt em branch saem da action inteira (semântica esperada). Aninhamento livre.
{ type: "if",
cond: (_ctx, scope) => scope.hp > 0,
then: [{ type: "state", target: "status", value: "alive" }],
else: [{ type: "throw", message: "dead" }],
}cond lança → erro coletado em errors[], nenhum branch executa, prossegue. JIT emite if/else JS nativo unrolled (zero call overhead).
Diretivas suportam:
as— capturaresult.datano scope:scope["prev"] = result.datacatch— em caso de falha, executa sub-diretivas comscope.$exceptionhalt— handler retorna{ ok: true, halt: true }para saída antecipadaresolve— campos dinâmicos mesclados antes da chamada:resolve(ctx, scope) → campos mesclados
Registro
const result = engine.register(actions);O pipeline de registro:
- Validate — valida estrutura via
handler.validate()(rejeita diretivas malformadas). Diretivas reservadas (const,let,return,throw) são reconhecidas pelotypee não precisam de handler. - Store — armazena no registry
- Compile — compila executor (interpret/JIT)
- Emit — emite evento
registervia lifecycle events
Retorna RegisterResult:
{
registered: string[]; // IDs registrados com sucesso
errors: RegisterError[]; // Falhas de validação
warnings: RegisterWarning[]; // NO_ANALYZE, ANALYZE_ERROR
}
// RegisterError — ADR-038: register-time é sempre dado coletado
// (boot-as-data). `code` é o contrato estável que o consumer
// discrimina; `error` (message) é cosmético; `data` carrega o detalhe.
interface RegisterError {
actionId: string;
error: string; // message — não-contratual
code: string; // obrigatório — REGISTER_ERROR_CODES.* ou code opaco do consumer
data?: unknown; // detalhe acionável (mantém os codes coarse)
}Lei de disposição (ADR-038). Register-time nunca lança por erro
estrutural — emite sempre RegisterError coletado. Handler ausente,
sub-directive required/não-array, target não-resolvido (UNRESOLVED_TARGET),
shape inválido — tudo é dado coletado, a action não registra, as demais
seguem. O único throw em register é o fatal opt-in do consumer
(seam de validador → DirectivePolicyError estrutural). Taxonomy de
origem do engine exportada em REGISTER_ERROR_CODES
(INVALID_SHAPE, MISSING_ID, UNKNOWN_HANDLER, UNRESOLVED_TARGET,
MISSING_INTERACTIVE_VARIANT, PAUSE_REQUIRES_INTERACTIVE,
DIRECTIVE_VALIDATOR_ISSUE).
Desregistrar
engine.unregister("checkout");
// Também remove filhas: "checkout/charge", "checkout/validate", etc.Modo Batch
Batch agrupa múltiplos registros e adia emissão de eventos pro endBatch():
engine.beginBatch();
engine.register([actionA]);
engine.register([actionB]);
const result = engine.endBatch(); // Único evento "register" emitidoSuporta aninhamento — endBatch() interno é no-op. Processamento acontece na profundidade 0.
Invocação
// Passe ctx direto na invocação (recomendado)
const result = engine.invoke("heal", undefined, ctx);
const result = engine.invoke("heal", { amount: 50 }, ctx);
// Ou defina ctx global e invoque sem passar ctx
engine.setContext(ctx);
const result = engine.invoke("heal");
// Ou ctx temporário com escopo (múltiplas invocações)
engine.context(ctx, () => {
engine.invoke("heal");
engine.invoke("combat");
});
// Async (obrigatório se algum hook ou handler for async)
const result = await engine.invokeAsync("heal", undefined, ctx);Três formas de fornecer ctx, em ordem de preferência:
| Forma | Quando usar |
|-------|-------------|
| invoke(id, params, ctx) | Invocação única, ctx por chamada — mais direto |
| context(ctx, fn) | Múltiplas invocações com mesmo ctx temporário |
| setContext(ctx) | Ctx global estável entre múltiplas invocações |
Se ctx for passado no invoke/invokeAsync, tem precedência sobre setContext/context. Se omitido, fallback pro ctx global (erro se nenhum definido).
Retorna DirectiveResult:
{
success: boolean;
aborted: boolean;
abortedBy?: string; // "halt" | "throw" | "maxDepth"
appliedCount: number;
skippedCount: number;
errors: DirectiveError[];
data?: unknown; // De return directive ou halt
counters: FrameCounters;
}Nunca lança em falha de handler ou condição de domínio. Exceções de handlers e { throw }/validação de negócio são dado — coletados em result.errors, o consumer reage (ADR-005).
Lança em erro de programa (carve-out ADR-005, estendida pelo ADR-036): invoke sem ctx; invoke quando isAsync; e target estrutural não-resolvido — { type:"action", id } com id fora do registry é undefinedFunction(), não no-op soft. Ids estáticos falham já no register/endBatch (RegisterError UNRESOLVED_TARGET, antes do tick zero); id dinâmico fora do registry lança ActionNotFoundError em runtime — tagueado estrutural, não-catchável pelo catch da diretiva (resolução de referência ≠ falha de execução). O consumer distingue via isStructuralError(e) / e instanceof ActionNotFoundError (exportados) e mapeia pra sua política. Ver ADR-036.
Sub-Actions (Visibilidade)
Ações com / no ID são privadas:
engine.register([
{ id: "checkout/validate", directives: [...] }, // privada
{ id: "checkout", directives: [
{ type: "action", id: "checkout/validate" }, // ok — escopo do pai
]},
]);
engine.invoke("checkout/validate"); // erro — não acessível da raizLifecycle Events
O engine emite eventos de ciclo de vida via on(). Retorna função de unsubscribe idempotente:
const unsub = engine.on("register", (event) => {
console.log("Registrados:", event.registered);
console.log("Resultado:", event.result);
});
engine.on("unregister", (event) => {
console.log("Removido:", event.id);
console.log("Cascata:", event.cascaded);
});
// Cancelar subscription
unsub();| Evento | Payload | Quando |
|--------|---------|--------|
| register | { actions, result, registered } | Após register() ou endBatch() |
| unregister | { id, cascaded } | Após unregister() |
Em batch mode, o evento register é emitido uma única vez no endBatch() com todas as actions do batch.
Read-Only Accessors
Acessores de leitura para introspection por camadas externas (e.g. analyzer):
// Map de handler definitions V2 registradas
engine.handlerDefinitions; // ReadonlyMap<string, HandlerDefinition>
// Set de IDs de todas as actions no registry
engine.registeredIds; // ReadonlySet<string>
// Lê uma action definition pelo ID
engine.getActionDefinition("heal"); // ActionDefinition | undefined
// Campo de tipo pra dispatch de diretivas
engine.typeField; // string (default: "type")
// Map `type → readonly fieldNames[]` dos campos de sub-directives no grafo
// (HandlerDefinition.subDirectives com graph !== false; "catch" filtrado)
engine.subDirectiveFieldsForGraph; // ReadonlyMap<string, readonly string[]>
// Mapa de permissões de diretivas (computado no boot, imutável)
engine.directivePermissions; // ReadonlyMap<string, DirectivePermission>
// Slots de hooks preenchidos (nomes dos hooks registrados)
engine.directiveHookSlots; // ReadonlySet<string>
// Slots async (nomes dos hooks que são async)
engine.asyncSlots; // ReadonlySet<string>Modos de Compilação
createActionEngine({ handlers, mode: "interpret" }); // Interpretador loop-based (dev)
createActionEngine({ handlers, mode: "jit" }); // Compila tudo no register (prod)
createActionEngine({ handlers, mode: "auto" }); // Interpreta primeiro, promove após N invocações (padrão)| Modo | Register | Runtime | Ideal para |
|------|----------|---------|------------|
| interpret | Rápido | Loop interpretado | Desenvolvimento, debug |
| jit | Mais lento (compila) | new Function compilado | Produção, ações estáveis |
| auto | Rápido | Promove per-action após threshold | Uso geral (padrão) |
Decisão sync/async é per-action. No JIT per-action, cada action é compilada sync ou async independentemente, baseado nos handlers que ela usa. Em um engine híbrido, actions 100% sync ficam com wrapper sync e zero await; actions com pelo menos um handler async ficam com wrapper async e await em todos os handlers da action. Isso garante que uso fully-sync (FPS, game loops) não pague o custo de async functions só porque outro handler do mesmo engine é async.
Threshold de auto-promote (padrão: 8):
createActionEngine({ handlers, mode: "auto", autoJitThreshold: 4 });Forçar compilação de todas as ações registradas:
engine.compile();Informações de compilação:
engine.isAsync; // true se algum hook OU handler é async
engine.compilationMode; // "interpret" | "jit" (modo atual, não o requestado)Hooks
Três pontos de hook em nível de diretiva:
createActionEngine({
handlers,
directiveHooks: {
beforeDirective(directive, frame) {
// Retorne "skip" pra pular, "abort" pra abortar
// Retorne { directive } pra substituir, { ctx } pra sobrescrever contexto
},
afterDirective(directive, result, frame) {
// Retorne "abort" pra parar execução
},
onDirectivesComplete(result) {
// Fire-and-forget — observa resultado final
},
},
});Hooks podem ser sync ou async. Hooks async tornam o engine async (engine.isAsync === true), exigindo invokeAsync(). Handlers async (via flag async: true ou async function) também tornam o engine async — ver Handlers Async.
Custo zero quando ausente. A compilação JIT não emite código de hook para hooks não registrados.
Action Hooks
Hooks no nível de action — interceptam antes e depois de executar todas as diretivas de uma action. Diferente de directiveHooks que operam por diretiva individual.
createActionEngine({
handlers,
actionHooks: {
beforeAction(id, params, frame) {
// Antes de executar qualquer diretiva da action.
// Retorne void pra prosseguir normalmente.
// Retorne { skip: true, data? } pra skip total (zero diretivas processadas).
},
afterAction(id, params, result, frame) {
// Após execução completa. NÃO dispara se beforeAction skipou.
// Retorne void pra manter resultado original.
// Retorne DirectiveResult pra substituir.
},
},
});Use cases: memoização, profiling, auditoria, mock/dry-run, governance.
Memoização via beforeAction
O engine não sabe o que é memo. O consumer implementa a lógica via closure (ADR-017):
const cache = new Map<string, { data: unknown }>();
const engine = createActionEngine({
handlers,
actionHooks: {
beforeAction(id) {
const cached = cache.get(id);
if (cached) return { skip: true, data: cached.data };
},
afterAction(id, _params, result) {
cache.set(id, { data: result.data });
},
},
});Comportamento
- Frame: O hook recebe o
childFrame(scope filho, depth incrementado), não o frame do caller. - Skip e auto-promote: Skip não incrementa
invokeCount— não conta pro threshold de auto-promote. - Skip e afterAction: Se
beforeActionskipar,afterActionnão dispara. - Sub-actions: Hooks disparam pra cada action na cadeia de invocação (parent e child).
- Sem hooks: Custo zero — um null check por invoke.
- JIT: Zero impacto. Hooks vivem no caller (
_invokeInternal), não no código gerado.
Handler Permissions
Controle declarativo de quais diretivas uma instância do engine pode usar. Útil pra governança (sandbox parent→child) e arquitetura (query actions não devem mutar estado).
// Whitelist: só estes tipos permitidos
createActionEngine({
handlers,
allowedDirectives: ["state:query", "emit", "action"],
});
// Blacklist: estes tipos bloqueados
createActionEngine({
handlers,
blockedDirectives: ["state:dispatch", "emit"],
});
// Blacklist com motivações
createActionEngine({
handlers,
blockedDirectives: [
{ pattern: "state:dispatch", reason: "sandbox read-only", source: "parent:root" },
{ pattern: "emit", reason: "events disabled", source: "config" },
],
});Regras:
allowedDirectiveseblockedDirectivessão mutuamente exclusivos. Se ambos fornecidos: erro no constructor.- Se nenhum fornecido: tudo permitido (default, zero overhead).
- Diretivas estáticas (
const,let,return,throw) são sempre permitidas — não podem ser bloqueadas. - Patterns com wildcard
*são suportados:"state:*","*:dispatch","*".
O engine não bloqueia execução. Permissions são config, não enforcement. Actions com handler denied registram e executam normalmente. Quem valida violations é o ActionAnalyzer. Quem decide a política (rejeitar boot, warning, ignorar) é o consumer.
// Consultar permissões
const perms = engine.directivePermissions;
perms.get("emit");
// → { status: "available" }
perms.get("state:dispatch");
// → { status: "denied", reason: "sandbox read-only", source: "parent:root" }
// Handler inexistente: não está no mapa (unavailable é derivado)
perms.has("notify");
// → falseValidadores de Diretiva
Validação estrita registrada de fora. O consumer injeta predicados opacos rodados por diretiva no register (FASE 1). Útil quando outra lib monta actions em runtime (sandbox/eval) e precisa barrar diretiva proibida — ou uso suspeito de diretiva permitida — antes da action existir. A política é 100% do consumer; o engine não conhece a semântica (ex.: o que é "estado privado").
createActionEngine({
handlers,
directiveValidators: [
(directive, path, actionId) =>
directive.type === "dispatch" && directive.state === "realm"
? {
message: "mutação de estado privado do sistema",
code: "RESERVED_TARGET_WRITE", // opaco ao engine — política do consumer
data: { target: "realm" },
fatal: true,
}
: null,
],
});Contrato:
(directive, path, actionId) => DirectiveIssue | null | undefined.null/undefined= ok.DirectiveIssue=string(sugar) ou{ message, code?, data?, fatal? }.code/datasão opacos ao engine — política 100% do consumer; o engine só transporta (não interpreta a semântica de "realm").messageé cosmético, não-contratual.- Issue não-fatal →
RegisterErrorcoletado; a action não registra.code/datapropagam proRegisterError. Semcode(inclusive a formastring) → defaultDIRECTIVE_VALIDATOR_ISSUE— nunca code-less (ADR-038): o consumer discrimina porcode, não por regex-em-message. - Issue
fatal→ lançaDirectivePolicyErrornoregister— erro estrutural, não-catchável,isStructuralError(e)true. Carregaissue.code/issue.data(defaultcode: "DIRECTIVE_POLICY_VIOLATION") +actionId/path. Simetria: a mesma policy é discriminável pelo mesmocodenas duas disposições (não-fatal coletado e fatal throw). Superfície estável pro consumer mapear (ex.: TICK_EXCEPTION). - Lista: roda todos; coleta todas as issues não-fatais por action; uma
fatalcurto-circuita a action. - Custo zero se ausente — nada é gerado nem chamado.
- Vê toda diretiva alcançável (recursivo em
catch,if.then/else, sub-directives declaradas);pathé o caminho do nó (ex.:directive[0].then[1]).
Ver ADR-037 (mecanismo do seam) e ADR-038 (contrato code/data, lei de disposição).
Referência de Configuração
interface IActionEngineConfig<TCtx> {
handlers: HandlerInputMap<TCtx>; // Obrigatório — handlers de diretivas
typeField?: string; // Campo de dispatch (padrão: "type")
directiveHooks?: DirectiveHooks<TCtx>; // Hooks de diretiva
limits?: Partial<FrameLimits>; // maxDepth (10), maxRules, maxDirectives
mode?: "interpret" | "jit" | "auto"; // Modo de compilação (padrão: "auto")
autoJitThreshold?: number; // Auto-promote após N invocações (padrão: 8)
allowedDirectives?: DirectivePermissionEntry[]; // Whitelist com pattern matching
blockedDirectives?: DirectivePermissionEntry[]; // Blacklist com pattern matching
directiveValidators?: DirectiveValidator[]; // Validadores estritos no register (FASE 1)
}Exports
// Factory
import { createActionEngine } from "@statedelta-actions/actions";
// Tipos (V1)
import type {
DirectiveHandler,
DirectiveHandlerMap,
DirectiveHooks,
DirectiveExecutorFn,
DirectiveRunnerFn,
} from "@statedelta-actions/actions";
// Tipos (V2)
import type {
HandlerDefinition,
HandlerAnalysis,
ValidationResult,
SubDirectiveFieldConfig,
HandlerInput,
HandlerInputMap,
ActionDefinition,
RegisterResult,
RegisterError,
RegisterWarning,
IActionEngineConfig,
IActionEngine,
} from "@statedelta-actions/actions";
// Lifecycle Events
import type {
RegisterEvent,
UnregisterEvent,
EngineEventMap,
EngineEventName,
} from "@statedelta-actions/actions";
// Directive Permissions
import type {
DirectivePermissionStatus,
DirectivePermission,
DirectivePermissionConfig,
DirectivePermissionEntry,
} from "@statedelta-actions/actions";
// Directive Validators + Structural Errors
import type {
DirectiveValidator,
DirectiveIssue,
} from "@statedelta-actions/actions";
import {
ActionNotFoundError,
DirectivePolicyError,
isStructuralError,
REGISTER_ERROR_CODES, // taxonomy estável de code de register (ADR-038)
DIRECTIVE_POLICY_VIOLATION, // default do code fatal
} from "@statedelta-actions/actions";
// Action Hooks
import type {
ActionHooks,
ActionInterceptResult,
} from "@statedelta-actions/actions";
// Interactive
import type {
InteractiveConfig,
PauseEvent,
InteractiveSession,
AsyncInteractiveSession,
InteractiveApplyResult,
Responder,
} from "@statedelta-actions/actions";
import {
drainSync,
drainAsync,
replayResponder,
} from "@statedelta-actions/actions";
// Register Pipeline
import { RESERVED_TYPES } from "@statedelta-actions/actions";
// Emitter
import { SimpleEmitter } from "@statedelta-actions/actions";
import type { Listener } from "@statedelta-actions/actions";
// Interpreter
import { createDirectiveInterpreter } from "@statedelta-actions/actions";
// JIT (per-action)
import { buildActionExecutor } from "@statedelta-actions/actions";
import type { GeneratedActionExecutor } from "@statedelta-actions/actions";Modo Interactive
Pausa execução de uma action e aguarda input externo do customer. Implementado via generators (sync ou async), ortogonal ao modo async.
Use cases:
- UX interativa em produção (wizards, prompts, confirmações)
- Debug entre diretivas (futuro — modo separado)
Mecânica:
- Handler interactive declarado com
interactive: true(espelhaasync: true) — handler é generator function - Diretiva reservada
type: "pause"— engine emite yield direto pra confirmação/breakpoint declarativo - Mini-graph interno propaga
_interactiveActionstransitivamente (ADR-026) - API:
engine.invokeInteractive(id, params, ctx)retornaIterator | AsyncIterator
Habilitação
const engine = createActionEngine({
handlers,
interactive: {}, // habilita modo interactive
});Sem interactive configurado:
- Handler com
interactive: true→ erro no constructor (fail-fast) - Diretiva
type: "pause"→ erro no register
Handler interactive
Handlers são primitivas Lego — genéricas, sem semântica de domínio (ADR-028). Vocabulário canonical pequeno (state, emit, action, log, halt) + handler interactive primitivo proposto: input (yield + schema + retry).
⚠️ Não use nomes domain-specific (
askUser,confirmDelete,validateEmail). Cria explosão combinatória, polui vocabulário canonical e viola o princípio Lego. Customer compõe domínio via payload das diretivas, não criando handlers.
// Pattern recomendado: primitiva `input` genérica com schema + retry interno
const handlers = {
input: {
interactive: true,
*execute(directive) {
let lastError: string | null = null;
let attempt = 1;
const max = directive.maxAttempts ?? Infinity;
while (attempt <= max) {
const answer = yield {
kind: "input",
payload: directive.payload,
schema: directive.schema,
attempt,
lastError,
};
if (!directive.schema) return { ok: true, data: answer };
const r = directive.schema.parse(answer);
if (r.ok) return { ok: true, data: r.data };
lastError = r.error;
attempt++;
}
return { ok: false, error: `validation failed after ${max} attempts` };
},
},
};Customer compõe actions com payload domain-specific sobre a primitiva:
{ id: "register", directives: [
{ type: "input", payload: { kind: "text", label: "Nome" }, as: "name", schema: nameSchema },
{ type: "input", payload: { kind: "number", label: "Idade" }, as: "age", schema: ageSchema },
{ type: "input", payload: { kind: "confirm", message: "Confirma?" }, as: "ok" },
{ type: "state", target: "users", op: "push", value: { name: ..., age: ... } },
]}Consumer interpreta payload.kind — renderiza UI/CLI/voice. Framework não opina.
Schema agnóstico — interface mínima { parse(raw): { ok: true, data } | { ok: false, error } }. Customer adapta zod/valibot/json-schema/custom em 1 linha.
input(intra-tick) ≠request(cross-tick).inputpausa dentro de uma invocação (yield/next imediato).request(RealmSystem futuro, fora deste package) anota dependência declarativa, lock cross-tick. São conceitos distintos com nomes propositalmente diferentes.
Auto-detect via isGeneratorFunction(execute) cobre function* e async function*. Use a flag interactive: true pra wrappers que retornam iterator sem ser generator function.
Diretiva type: "pause" (engine-level)
{ type: "pause", message: "Confirmação destrutiva?", as: "ack" }Engine yield direto:
session.next(); // → { source: "pause", payload: { message: "..." }, ... }
session.next("ok"); // → continua, captura "ok" em scope.ack
session.next(false); // → aborta (abortedBy: "pause")
session.next("cancel"); // → aborta
session.next("abort"); // → abortaDrenagem do iterator
// Customer dirigindo manualmente
const session = engine.invokeInteractive("wizard", undefined, ctx);
const r1 = session.next(); // PauseEvent ou payload custom
const r2 = session.next("Anderson"); // entrega resposta
// quando r.done === true, r.value é o DirectiveResult finalHelpers opcionais pra "play mode":
import { drainSync, drainAsync, replayResponder } from "@statedelta-actions/actions";
// Drena com responder programático
const result = drainSync(session, (event) => {
// event pode ser PauseEvent (type:"pause") ou payload custom (handler)
if ("prompt" in event) return responses[event.prompt];
if ((event as PauseEvent).source === "pause") return "ok";
return undefined;
});
// Replay determinístico (testes)
const result = drainSync(session, replayResponder(["Anderson", 42, "yes"]));Sub-actions interativas (yield* propaga pausas)
Quando action root invoca child interactive, pausas do child fluem pro consumer no nível raiz via yield*. Handler action (consumer-defined) decide:
const handlers = {
action: {
execute: (d, f, e) => {
if (e.isActionInteractive(d.id)) {
// Target é interactive → propaga via iterator
return { ok: true, iterator: e.invokeInteractive(d.id, d.params, f) };
}
// Target sync → invoke regular
const r = e.invoke(d.id, d.params, f);
return { ok: r.success, data: r.data };
},
},
};Engine consulta mini-graph e marca actions transitivamente interativas. JIT detecta _result.iterator em runtime e emite yield* automaticamente.
Matriz async × interactive
| Engine async? | Action interactive? | Compilação |
|---------------|---------------------|------------|
| não | não | function () |
| não | sim | function* () |
| sim | não | async function () |
| sim | sim | async function* () |
Granularidade per-action via mini-graph (ADR-021/025/026). FPS 100% sync com 1 action interactive isolada não paga overhead de generator nas outras.
invoke() per-action transitivo
invoke() lança per-action (não global):
engine.invoke("syncAction"); // OK — sub-árvore inteira sync
engine.invoke("asyncAction"); // throw "use invokeAsync"
engine.invoke("interactiveAction"); // throw "use invokeInteractive"Engine híbrido (handler async + actions sync isoladas) permite invoke() regular nas sync. ADR-026.
Action hooks fora do generator
beforeAction / afterAction (ADR-019) executam antes e depois do generator. Skip via beforeAction retorna memoResult imediatamente. afterAction substitui o resultado final.
Catch atômico
Diretivas dentro de catch executam atomicamente (ADR-023) via engine.runDirectives — sem yield. Caminho de erro não pausa. Diretivas type: "pause" ou handler interactive dentro de catch geram warning em register-time.
Auto-step directives (debug mode)
Pra debuggers, profilers, time-travel UIs ou dry-run que precisam pause-point universal sem instrumentar definitions, habilite autoStepDirectives no config interactive:
const engine = createActionEngine({
handlers,
interactive: { autoStepDirectives: true },
});O interpreter generator passa a emitir PauseEvent { source: "step" } antes de cada directive durante uma invocação interactive. Consumer drena via session.next(cmd?) e pode:
| next(cmd) | Efeito |
|-------------|--------|
| omitido | continua — executa a directive normalmente |
| { skip: true } | pula a directive (incrementa skippedCount) |
| { replaceWith: directive } | substitui antes de executar |
| { abort: true } | aborta com abortedBy: "step" |
Coexiste com pause directive e handlers interactive — em uma action que mistura tudo, a ordem de yields é: step (engine antes de processar) → yield natural (pause payload, ou handler yield*).
const session = engine.invokeInteractive("checkout", undefined, ctx);
let r = session.next();
while (!r.done) {
const ev = r.value as PauseEvent;
if (ev.source === "step") {
console.log("next:", ev.directive.type, "at", ev.frame.path);
r = session.next(); // continua
} else if (ev.source === "pause") {
r = session.next("ok"); // responde pause
} else {
r = session.next(responses[ev.payload?.prompt]); // handler
}
}Interpret-only. JIT permanece atômico por design — pause-points em código compilado violariam o propósito do JIT. Engine em mode: "jit" com autoStepDirectives: true faz fallback pro interpreter generator durante invocações interactive das actions transitivamente marcadas. Invocações regulares (invoke/invokeAsync) e actions não-interactive continuam usando JIT normalmente — autoStep só desvia o caminho interactive.
Propagação transitiva grátis. Step yields propagam automaticamente via yield* em sub-actions interactive (sem código adicional) e em sub-blocos de wrapper handlers que delegam via engine.runDirectivesInteractive. Catch permanece atômico — engine.runDirectives (interpreter regular, não-generator) não emite step yields, preservando ADR-023.
replayResponder e step events. Em play mode, replayResponder ignora step events sem consumir a queue de respostas — pré-programados continuam alinhados aos yields semânticos (pause / handler).
Custos. Sem a flag: zero overhead. Com a flag: 1 yield + 1 PauseEvent alloc por directive executada (proporcional ao tamanho da action — desprezível em debug humano). Fallback JIT→interpreter custa ~10× mais lento que JIT compilado (aceitável — debug é interação humana, não hot path).
Analyzer
Funcionalidades de análise estática foram extraídas para o pacote @statedelta-actions/analyzer:
- Grafo de dependências — capabilities, leaf, maxDepth, ciclos
- Declarations — trust boundaries, conflitos de contrato
- Propagators — propriedades computadas e propagadas pelo grafo
- Composition Control — manifest declarativo, tags, matchers, transitividade
O analyzer consome os read-only accessors e lifecycle events do engine. Consulte a documentação do @statedelta-actions/analyzer para detalhes.
Licença
MIT
