@statedelta-actions/actions
v0.15.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, createActionHandler } 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 };
},
},
// Composição canônica de actions — propaga halt uncatchable, propaga
// throw catcheável, suporta sync/async/interactive transitivo (ver
// [Handler `action` canônico](#handler-action-canônico--createactionhandler)).
action: createActionHandler(),
};
// 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 },
rescue: { required: false }, // renomeado de "catch" pra evitar colisão com o campo `catch` universal do envelope
finally: { required: false },
},
execute(d, frame, engine) {
const r = engine.runDirectives(d.body, frame);
if (!r.success && d.rescue) {
// r.error é o ErrorObject propagado (ver Modelo de Erro)
frame.scope.$caught = r.error;
engine.runDirectives(d.rescue, frame);
}
if (d.finally) engine.runDirectives(d.finally, frame);
// Preserva halt uncatchable; throw flatta pra ok:false catcheável.
if (r.aborted && r.abortedBy === "halt") {
return { ok: r.success, halt: true, data: r.data, error: r.error };
}
return r.success
? { ok: true, data: r.data }
: { ok: false, error: r.error };
},
},
};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. ifpermanece hardcoded — primitiva reservada de fluxo de controle (then/else) com otimizações inline no interpreter e JIT.catché subDirective field universal do envelope (ADR-040): todo handler type recebe"catch"automaticamente emsubDirectiveFieldsForGraph. Handler não pode declararcatchemsubDirectives— nome reservado, lança no construct. Sub-bloco de recuperação em wrapper handlers (try_/simulate/etc) usa outro nome (convenção:rescue).- 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"],
},
rescue: {
required: false,
purpose: "catch",
description: "Diretivas executadas em falha do body. Erro disponível via `catchAs` (ver Modelo de Erro).",
},
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, error: {code:"USER_THROW", message, data}
{ 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" }] }
{ type: "action", id: "charge", catchAs: "err" } // captura erro em scope.err, continuaO 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 → action aborta com error.code = "IF_COND_ERROR" (if é primitiva reservada, sem catch próprio — propaga single-tier). JIT emite if/else JS nativo unrolled (zero call overhead).
Diretivas suportam (campos do envelope, válidos em diretiva handler):
as: "name"— capturaresult.datano scope:scope.name = result.data(caminho de sucesso).catch: [d…]— em caso de falha (ok:false), roda as sub-diretivas; suprime propagação se o bloco não abortar (ver Modelo de Erro).catchAs: "name"— em caso de falha, captura oErrorObjectemscope.namee continua na próxima diretiva (binding lexical do erro). Pode coexistir comcatch:[](scope.name bindado + bloco roda).halt— handler retorna{ ok: true, halt: true }para saída antecipada uncatchable (não é campo do envelope — é flag noApplyResultdo handler). Ver Modelo de Erro > halt.resolve: (ctx, scope) => {…}— campos dinâmicos mesclados antes da chamada.
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). O erro segue a estrutura unificada `ErrorObject`
// (mesma forma em runtime e register — ver [Modelo de Erro](#modelo-de-erro)).
interface RegisterError {
actionId: string;
error: ErrorObject;
}
interface ErrorObject {
code: string; // contrato (consumer discrimina)
message?: string; // cosmético, não-contratual
data?: unknown; // detalhe acionável (mantém codes coarse)
cause?: ErrorObject; // chain de causa-efeito
}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, CATCH_ON_RESERVED, HANDLER_UNABLE,
MISSING_GRANT).
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" | "afterDirective" | "pause" | ...
appliedCount: number;
skippedCount: number;
errors: DirectiveError[]; // só erros de hook/infra — falhas de diretiva NÃO acumulam aqui
data?: unknown; // valor de return / halt success
error?: ErrorObject; // erro causador do abort (throw uncaught ou halt+ok:false)
counters: FrameCounters;
}
interface DirectiveError {
directiveIndex: number;
error: ErrorObject;
}Nunca lança em falha de handler ou condição de domínio. Falhas de diretiva propagam via result.aborted = true (abortedBy:"throw") com result.error populado — não são exceções JS. O consumer reage lendo o resultado. Ver Modelo de Erro.
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 raizModelo de Erro
Erro em runtime é estruturado como ErrorObject — mesma forma usada em register-time (ADR-038):
interface ErrorObject {
code: string; // contrato — consumer discrimina por aqui
message?: string; // cosmético, não-contratual
data?: unknown; // detalhe acionável (mantém codes coarse)
cause?: ErrorObject; // chain de causa-efeito (handler wrappa erro de child)
}code é sempre presente. Handler que retorna string crua ({ ok:false, error:"x" }) é wrappado pelo engine em { code:"HANDLER_FAILED", message:"x" }. Exception JS lançada pelo handler vira { code:"UNCAUGHT_EXCEPTION", message:String(err) }.
Single-tier — falha exige tratamento explícito
Diretiva handler falhou (ok:false)? Três caminhos:
| Combinação na diretiva | Comportamento |
|------------------------|---------------|
| catch:[d…] presente | Roda bloco; se catch sucede, ação continua. Se catch aborta, propaga. |
| catchAs:"e" presente | scope.e = error; ação continua na próxima diretiva (Go-like errback). |
| Ambos | scope.e bindado E bloco roda. |
| Nenhum | Ação aborta: aborted:true, abortedBy:"throw", error: ErrorObject. Propaga pro caller via invoke. |
Não existe "coleta-e-continua" — falha exige catch:[], catchAs, ou propaga. errors[] no DirectiveResult é canal de hook/infra apenas (beforeDirective/afterDirective lançando) — falha de diretiva nunca acumula lá.
// Bloco de recuperação (JS try/catch-like)
{ type: "charge", catch: [{ type: "emit", event: "payment:failed" }] }
// Captura como valor (Go-like errback) — continua sem rodar bloco
{ type: "charge", catchAs: "err" }
{ type: "if", cond: (c,s) => s.err, then: [...recovery...] }
// Sem nenhum dos dois → action aborta e propaga
{ type: "charge" }Resolve, binding e if.cond — também propagam
directive.resolve() lançando, let/const/return/throw resolve lançando, if.cond lançando — todos seguem single-tier. Para diretivas handler: catch:[] / catchAs pegam. Para primitivas reservadas (const/let/return/throw/if): não têm catch — sempre propagam com code específico (BINDING_ERROR, CONTROL_RESOLVE_ERROR, IF_COND_ERROR).
throw reservado emite ErrorObject
{ type: "throw", message: "saldo insuficiente" }
// → result.error = { code:"USER_THROW", message:"saldo insuficiente", data:"saldo insuficiente" }
// result.data = undefined
// result.aborted = true, abortedBy = "throw"halt — exit uncatchable
Distinto de throw: halt na flag do ApplyResult é exit uncatchable que atravessa invokes até o topo (RS/tick captura).
// Handler que sinaliza "tick terminou":
const halt: HandlerDefinition = {
execute: () => ({ ok: true, halt: true }),
};
// Tabela completa:| result.ok | result.halt | Resultado da action |
|---|---|---|
| true | false | Sucesso, continua próxima diretiva |
| true | true | Halt success uncatchable: aborted:true, abortedBy:"halt", success:true, data |
| false | false | Single-tier (catch/catchAs/propaga como throw) |
| false | true | Halt failure uncatchable: aborted:true, abortedBy:"halt", success:false, error |
Halt não roda catch:[]/catchAs da diretiva. E createActionHandler preserva o halt ao propagar pra cima — atravessa toda a chain de invokes sem que nenhum catch pegue. Modelo de tick/FPS: halt em qualquer profundidade termina o tick; throw é localizado e recuperável.
A diretiva { type:"halt" } não tem handler built-in — o engine só respeita a flag. Quem fornece o handler de halt é a camada acima (RealmSystem ou consumer), via composição como qualquer outro handler.
RUNTIME_ERROR_CODES — taxonomy de origem do engine
Codes que o engine fabrica quando o handler omite:
| Code | Origem |
|------|--------|
| HANDLER_FAILED | Handler retornou {ok:false} sem error.code, OU sem handler registrado |
| UNCAUGHT_EXCEPTION | Handler lançou exception JS (não-estrutural); interpreter wrappou |
| RESOLVE_ERROR | directive.resolve(ctx, scope) lançou em diretiva handler |
| BINDING_ERROR | const/let resolve lançou |
| CONTROL_RESOLVE_ERROR | return/throw resolve lançou |
| IF_COND_ERROR | if.cond lançou |
| USER_THROW | Diretiva throw reservada |
| HOOK_BEFORE_DIRECTIVE_ERROR | beforeDirective hook lançou (vai pra errors[], não propaga) |
| HOOK_AFTER_DIRECTIVE_ERROR | afterDirective hook lançou (idem) |
| MAX_DEPTH_EXCEEDED | limits.maxDepth excedido em invoke recursivo |
| PRIVATE_ACTION | Tentativa de invocar sub-action privada de fora do escopo do pai |
Helper wrapException(err, code, prefix?) exportado por @statedelta-actions/core padroniza wrapping de exceptions JS — preserva ErrorObject se err já for um (pass-through).
Resumo: throw vs halt
| Aspecto | throw (single-tier) | halt (exit) |
|---|---|---|
| Quem inicia | Handler ok:false sem halt; diretiva throw; resolve/cond throwing | Handler {ok, halt:true} |
| Catcheável na própria action? | catch:[] / catchAs pegam | Não — bypass total |
| Atravessa invoke? | Catcheável no caller (single-tier de novo) | Uncatchable — createActionHandler preserva halt |
| Use case | Erro de domínio recuperável | "Tick acabou" — exit do mundo |
Lifecycle 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");
// → falseCapability Gate — requires × hostCapabilities (ADR-042)
Handlers podem exigir capabilities do ambiente pra funcionar. Quando o ambiente não provê alguma capability exigida, o handler fica unable — fisicamente incapaz, distinto de denied (que é policy).
// Handler declara o que exige do ambiente
const simulate: HandlerDefinition = {
requires: ["async-host"], // ← capabilities do ambiente
subDirectives: { directives: { required: true } },
execute(d, frame, engine) { /* ... */ },
};
// Ambiente declara o que provê
const engine = createActionEngine({
handlers: { simulate, state, emit },
hostCapabilities: ["async-host"], // ← provê async-host
});
// simulate é `able`. directivePermissions.get("simulate") → { status: "available" }
// Ambiente SEM async-host:
const limited = createActionEngine({
handlers: { simulate, state, emit },
hostCapabilities: [], // ← não provê nada
});
// simulate é `unable`. directivePermissions.get("simulate") →
// { status: "unable", reason: "...", missing: ["async-host"] }able × unable — física, não policy. Diferente de denied:
| Estado | Categoria | Origem | Conserta-se |
|--------|-----------|--------|---|
| denied | policy | blockedDirectives (consumer escolheu bloquear) | mudando a policy |
| unable | física | requires ⊄ hostCapabilities (ambiente não provê) | mudando o ambiente |
Quando handler é simultaneamente denied e unable, unable vence — mesmo se a policy permitisse, ele continuaria sem rodar.
Existência, não alcançabilidade
Handler unable só existindo, sem uso = OK (aparece como unable em directivePermissions, sem erro de boot). Action que USA uma diretiva unable em qualquer profundidade da árvore (deep — diretivas top-level, dentro de catch, dentro de if.then/else, dentro de sub-directives declaradas) → RegisterError HANDLER_UNABLE coletado em FASE 1; action não registra.
const result = engine.register([
{
id: "broken",
directives: [
{ type: "fail", catch: [
{ type: "if", cond: true, then: [
{ type: "simulate" } // ← unable enterrado, mesmo deep falha
]}
]}
],
},
]);
// result.errors[0].error.code === "HANDLER_UNABLE"
// result.errors[0].error.data === {
// types: ["simulate"],
// missing: { simulate: ["async-host"] },
// }Não é alcançabilidade transitiva via grafo de invokers — cada action é gatada por existência na própria árvore. Recursos são boot-defined e imutáveis (ADR-011); capability ausente no construct nunca vira disponível depois. RealmSystem (composition root) discrimina por code === "HANDLER_UNABLE" e aborta o boot.
Custo
Zero quando feature não usado (sem hostCapabilities/requires no config). Quando ativo: 1 Set.has por nó durante o structuralWalk (mesmo padrão de localAsync). Resolução em construct: O(handlers × requires).
hostCapabilities é imutável — resolvido 1× no construct, sem setter. É o que torna o gate definitivo no boot.
Ver ADR-042.
Grant Gate — requiresGrants × grants (ADR-043)
Handlers podem ser privilegiados — exigir que a action portadora declare um grant pra usá-los. Quando uma action comum (sem o grant) usa uma diretiva privilegiada, ela falha o boot com MISSING_GRANT. É autorização por-action, distinta de unable (física do host) e denied (policy): a diretiva existe e está plenamente capaz — quem não tem direito de acesso é a action.
// Handler declara o grant que a action precisa portar
const superDelete: HandlerDefinition = {
requiresGrants: ["macro"], // ← eixo de autorização (ADR-043)
execute(d, frame) { /* ... */ },
};
const engine = createActionEngine({ handlers: { superDelete, state, emit } });
// Action de categoria privilegiada — declara o grant
engine.register([
{ id: "purge", grants: ["macro"], directives: [{ type: "superDelete" }] },
]);
// → registra normal
// Action comum — sem o grant
const result = engine.register([
{ id: "cleanup", directives: [{ type: "superDelete" }] },
]);
// result.errors[0].error.code === "MISSING_GRANT"
// result.errors[0].error.data === {
// types: ["superDelete"],
// missing: { superDelete: ["macro"] },
// }As três categorias de "referência impossível":
| Code | Significado | Conserta-se |
|------|-------------|---|
| UNKNOWN_HANDLER | não existe — type sem handler | registrando o handler |
| HANDLER_UNABLE | incapacidade física — host não provê requires (ADR-042) | mudando o ambiente |
| MISSING_GRANT | violação de autorização — existe e capaz, action comum sem grant | declarando o grant na action |
Existência, não propagação por invocação
Action que USA uma diretiva privilegiada sem o grant em qualquer profundidade da árvore (top-level, dentro de catch, if.then/else, sub-directives declaradas) → MISSING_GRANT em FASE 1; action não registra.
O grant é da definição, não da árvore de chamadas: uma action privilegiada pode invocar uma action comum, mas a comum não herda o grant — é gatada pela própria árvore (ela nem sabe quem a chamou).
engine.register([
{ id: "normal", directives: [{ type: "leaf" }] }, // sem privilegiada → OK
{ id: "macro", grants: ["macro"], directives: [
{ type: "superDelete" }, // OK — macro tem o grant
{ type: "action", id: "normal" }, // macro invoca normal
]},
]);
// ambas registram — "normal" não ganha grant por ser invocada por "macro"Custo
Zero quando feature não usado (nenhum handler declara requiresGrants). Quando ativo: 1 Set.has por nó privilegiado no structuralWalk (mesmo padrão de unable), + 1 Set por action com grants. Resolução em construct: O(handlers × requiresGrants). Grants são boot-defined e imutáveis (ADR-011) — o gate é definitivo no boot, standalone (sem analyzer).
Vocabulário genérico. "macro" é só uma string opaca — o engine só faz set-membership. Categorias de domínio (a noção de "macro", invoke("macro:talCoisa"), etc.) vivem na camada do consumer, que mapeia categoria → grants antes de entregar pro engine.
Ver ADR-043.
Validadores 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)
hostCapabilities?: readonly string[]; // Capabilities do ambiente — capability gate (ADR-042)
}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)
RUNTIME_ERROR_CODES, // taxonomy estável de code de runtime
DIRECTIVE_POLICY_VIOLATION, // default do code fatal
} from "@statedelta-actions/actions";
// Error contract (re-exportado do core)
import type { ErrorObject } from "@statedelta-actions/core";
import { wrapException } from "@statedelta-actions/core";
// 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
