@statedelta-libs/expressions
v5.0.0
Published
JSON DSL compiler for optimized functions - StateDelta expression engine
Maintainers
Readme
@statedelta-libs/expressions
Compilador de JSON DSL puro para funções otimizadas.
O que é
Um compilador que transforma expressões declarativas em JSON puro em funções JavaScript de alta performance. A entrada é sempre JSON serializável. A saída é sempre uma (data) => result.
O compilador não conhece nenhuma função — todas vêm via scope fornecido pelo consumer. O mesmo motor serve para matemática, queries de game engine, ETL, NoCode, ou qualquer domínio.
Instalação
pnpm add @statedelta-libs/expressionsQuick Start
import { ExpressionCompiler, pipePlugin } from '@statedelta-libs/expressions';
const compiler = new ExpressionCompiler({
scope: {
add: (a, b) => a + b,
multiply: (a, b) => a * b,
filter: (pred) => (arr) => arr.filter(pred),
sum: (arr) => arr.reduce((a, b) => a + b, 0),
},
// $pipe is a builtin plugin — opt in to enable it in your DSL
primitives: [pipePlugin],
});
// Compila uma vez
const compiled = compiler.compile({
$pipe: [
{ $: "items" },
{ $fn: "filter", args: [{ $arrow: { $: "item.active" }, args: ["item"] }] },
{ $fn: "sum" }
]
});
// Bind ao contexto → BoundFn callable
const fn = compiled.bind();
// Executa milhões de vezes
fn({ items: [{ active: true, price: 10 }, { active: false, price: 20 }] });
fn.deps; // ["items"]Os 4 Primitivos Core
| Primitivo | O que faz | Exemplo DSL | JS equivalente |
|-----------|-----------|-------------|----------------|
| $ | Referência a path | { $: "user.name" } | data.user.name |
| $fn | Invocação/referência de função | { $fn: "add", args: [...] } | add(a, b) |
| $if | Condicional | { $if: cond, then: a, else: b } | cond ? a : b |
| $arrow | Closure deferida | { $arrow: body, args: ["x"] } | (x) => body |
Tudo 100% JSON-serializável. Funções nunca aparecem no DSL — são referenciadas por nome e resolvidas contra o scope em runtime.
Builtin plugins prontos
Operadores que não são universais ficam fora do core e são fornecidos como plugins builtin opt-in:
| Plugin | Chave DSL | Como registrar |
|---|---|---|
| pipePlugin | $pipe | new ExpressionCompiler({ scope, primitives: [pipePlugin] }) |
Consumers (como @statedelta-axiom) podem adicionar primitivos próprios ($delta, $prev, …) registrando seus próprios plugins. Ver Primitive Plugins abaixo.
Dois Modos de Compilação
compiler.compile(expr); // closures — compilação rápida, ~9-20M ops/s
compiler.jit(expr); // JIT — compilação lenta, ~25-27M ops/s de execuçãoClosures compõe funções JavaScript aninhadas. Ideal para expressões executadas poucas vezes ou ambientes com CSP restritivo.
JIT gera código JavaScript via AST e new Function(). Ideal para hot paths executados muitas vezes. Break-even em ~8 execuções.
3 Fases: Compile → Bind → Run
Compilação e contexto são independentes. Compile uma vez, bind N vezes com contextos diferentes — zero recompilação.
const compiler = new ExpressionCompiler({ scope });
// Fase 1: Compile (pesado — uma vez) → Artifact
const artifact = compiler.jit(expr);
// Fase 2: Bind (barato — só DI) → BoundFn callable
const forTenant1 = artifact.bind({ scope, accessor: tenant1Accessor });
const forTenant2 = artifact.bind({ scope, accessor: tenant2Accessor });
// Fase 3: Run
forTenant1(data); // usa accessor do tenant 1
forTenant2(data); // usa accessor do tenant 2BoundFn é callable direto — sem .fn. Funciona com .map(), .filter() e qualquer API que espera funções. Metadata como propriedades:
const fn = compiler.compile(expr).bind();
fn(data); // executa
fn.deps; // paths observados
fn.hash; // hash estruturalDI-first — construtor sem contexto
O compilador funciona sem contexto no construtor. Contexto é injetado via bind():
const compiler = new ExpressionCompiler(); // sem scope/accessor/handlers
const artifact = compiler.compile(expr);
const fn = artifact.bind({ scope, accessor });
fn(data);Compile cache compartilhável
CompileCache armazena artefatos context-free compartilháveis entre instâncias:
import { ExpressionCompiler, CompileCache } from '@statedelta-libs/expressions';
const sharedCache = new CompileCache(5000);
const tenantA = new ExpressionCompiler({ scope: scopeA, compileCache: sharedCache });
const tenantB = new ExpressionCompiler({ scope: scopeB, compileCache: sharedCache });
// Mesma expressão compilada uma vez, bind por tenant
tenantA.jit(expr).bind()(data); // compile cache miss → compile + bind
tenantB.jit(expr).bind()(data); // compile cache hit → só bindCenários habilitados:
- Multi-tenant —
CompileCachecompartilhado, bind por tenant - Hot-swap de accessor por tick — compile uma vez, bind a cada tick
- Testing — mesmo artifact, scope mockado via bind
- Lazy bind — compile no boot, bind sob demanda
Extensibilidade
Scope — funções disponíveis
const compiler = new ExpressionCompiler({
scope: {
add: (a, b) => a + b,
filter: (pred) => (arr) => arr.filter(pred),
tryCatch: (arrow, params) => {
try { return arrow(); }
catch { return params?.fallback ?? null; }
},
},
});Normalize — DSL customizado
Transforma sugar syntax em DSL puro antes da compilação. Para quando o resultado é expression DSL válido.
const transforms = {
$double: (node) => ({ $fn: "multiply", args: [node.$double, 2] }),
};
const pure = compiler.normalize({ $double: { $: "value" } }, transforms);
compiler.compile(pure);Boundaries — compiladores externos
Intercepta nós durante a compilação e terceiriza para outro algoritmo. Para quando o nó precisa de um compilador completamente diferente. Boundaries são compile-time — ficam fixos entre bind() calls.
import type { BoundaryDef } from '@statedelta-libs/expressions';
// $raw — passthrough, nada é compilado
const rawBoundary: BoundaryDef = {
check: (node) => "$raw" in node,
handle: (node) => () => node.$raw,
};
// $rules — DSL estrangeiro com compilador próprio
const rulesBoundary: BoundaryDef = {
check: (node) => "$rules" in node,
handle: (node) => {
const compiled = ruleEngine.compile(node.$rules);
return (data) => compiled.evaluate(data);
},
};
const compiler = new ExpressionCompiler({
scope,
boundaries: [rawBoundary, rulesBoundary],
});
// $rules é interceptado pelo boundary, compilado pelo ruleEngine
compiler.compile({
$fn: "add",
args: [{ $: "base" }, { $rules: { when: "vip", then: 20 } }]
});O handler é uma closure auto-suficiente — captura o que precisa (outros compiladores, databases, etc.) por fora. Zero overhead quando não há boundaries registrados.
| | normalize() | BoundaryDef |
|---|---|---|
| Quando roda | Antes da compilação | Durante a compilação (no walk) |
| Retorno | Expression (DSL puro) | CompiledFn (função pronta) |
| Uso típico | Sugar syntax | DSL estrangeiro, $raw, rule engines |
| bind() | N/A | Fixo (compile-time) |
Primitive Plugins — estendendo a família dos primitivos
Quando você precisa adicionar um operador novo da mesma família dos primitivos ($delta, $prev, $tick, etc.) com integração completa (deps, validate, JIT codegen, IR-native), use a Primitive Plugin API. Diferente de BoundaryDef (que é opaco), plugins são cidadãos de primeira classe.
import type { PrimitivePlugin } from '@statedelta-libs/expressions';
// Plugin sugar — desugara para DSL puro
const composePlugin: PrimitivePlugin = {
key: "$compose",
match: (node) => "$compose" in node && Array.isArray(node.$compose),
analyze: (node) => ({
kind: "desugar",
expr: {
$arrow: { $fn: "pipe", args: [{ $: "_" }, ...[...node.$compose].reverse()] },
args: ["_"],
},
}),
};
// Plugin IR-native — gera código próprio nos dois backends
const incPlugin: PrimitivePlugin = {
key: "$inc",
match: (node) => "$inc" in node,
collectDeps: (node, collect) => {
if (typeof node.$inc === "string") collect(node.$inc);
},
analyze: (node, ctx) => {
const child = ctx.analyzeChild(
typeof node.$inc === "string" ? { $: node.$inc } : node.$inc as never,
);
return { kind: "ir", data: null, children: [child] };
},
visitClosure: (_data, _ctx, children) => {
const [childFn] = children;
return (d) => (childFn(d) as number) + 1;
},
visitJIT: (_data, ctx, children) => {
const [childAst] = children;
return ctx.b.binaryExpression("+", childAst, ctx.b.literal(1));
},
};
const compiler = new ExpressionCompiler({
scope,
primitives: [composePlugin, incPlugin, pipePlugin],
});Dois modos de analyze:
kind: "ir"— plugin emitedataopaco +children?(sub-IR pré-walked) +channels?(accessor channels a registrar). Tem seus própriosvisitClosure/visitJIT.kind: "desugar"— plugin retornaExpressionreanalisada normalmente. Loop protection embutido.
Comparativo com outras extensões:
| | scope | handlers | BoundaryDef | PrimitivePlugin |
|---|---|---|---|---|
| Natureza | Funções puras | Services com this | DSL estrangeiro | Operador da família |
| Acesso a IR | Nenhum | Nenhum | Opaco (não percorrido) | Total (visitors + ctx) |
| extractDeps | Walks args | Walks args | Cego (opaco) | Plugin contribui |
| validate | Sim | Sim | Skip | Plugin valida |
| JIT codegen | Param flat | Param flat | Param indexado opaco | Inline + helpers |
| bind() | Trocável | Trocável | Fixo (compile-time) | Fixo (compile-time) |
Plugin keys participam da identidade do CompileCache — diferentes plugin sets não colidem.
Handlers — services com contexto
O scope é para funções puras e stateless. Quando o consumer precisa de services inteligentes que acessam o sistema (accessor, outros handlers, o compilador, o scope), usa handlers.
import type { HandlerContext } from '@statedelta-libs/expressions';
const compiler = new ExpressionCompiler({
scope: { add: (a, b) => a + b },
handlers: {
query: {
find(key: string) {
return db.find(key);
},
findAll() {
// chamar outro handler
const valid = this.handlers.validation.check("all");
return valid ? db.findAll() : [];
},
},
validation: {
check(value: unknown) {
// chamar scope fn
return this.scope.add(value != null ? 1 : 0, 0) > 0;
},
},
},
});Handlers são invocados via $fn com sintaxe "namespace:method":
{ "$fn": "query:find", "args": [{ "$": "userId" }] }
{ "$fn": "validation:check", "args": [{ "$": "value" }] }O contexto é acessado via this, injetado automaticamente via .bind() no construtor. O HandlerContext é criado uma vez — zero alocação por chamada:
| Campo | O que contém |
|-------|-------------|
| this.accessor | Resolver de paths customizado |
| this.handlers | Todos os handlers (wrapped) — permite composição entre handlers |
| this.compiler | Instância do compilador — permite compilar sub-expressões |
| this.scope | Funções puras do scope |
Handlers devem ser regular functions ou method shorthand (arrow functions ignoram .bind()):
// method shorthand — funciona
handlers: { query: { find(key) { this.scope... } } }
// regular function — funciona
handlers: { query: { find: function(key) { this.scope... } } }
// arrow function — NÃO funciona (this é undefined)
handlers: { query: { find: (key) => { this.scope... } } }| | scope | handlers |
|---|---|---|
| Natureza | Funções puras (Ramda-style) | Services com contexto |
| Acessa | Apenas args compilados | this (HandlerContext) + args |
| DSL | { $fn: "add", args: [...] } | { $fn: "query:find", args: [...] } |
| Binding | Nenhum | .bind(ctx) uma vez no construtor |
| Overhead | Zero | Zero (ctx e bindings criados uma vez) |
| bind() | Trocável via ctx.scope | Trocável via ctx.handlers |
Zero overhead quando nenhum handler é registrado.
Accessor — objetos inteligentes
const compiler = new ExpressionCompiler({
scope,
accessor: (path) => tickContext.get(path), // closure auto-suficiente
});
// Hot-swap por tick via bind()
const artifact = compiler.jit(expr, { useAccessor: true });
const fn = artifact.bind({
scope,
accessor: (path) => newTickContext.get(path),
});PathRouter — accessors multi-canal
Toda referência { $: path } resolve por um canal de accessor. Com um accessor único, todo path vai pro canal main. Quando você tem várias fontes (state, env, params, effects…), um único accessor que faz path.split(":") em runtime tem custo a cada chamada. Em vez disso, forneça um pathRouter: uma função que roda em compile-time (uma vez por ref, dentro do analyze()) e classifica o path num canal. Cada canal vira um parâmetro flat dedicado no JIT (acc_state(...), acc_env(...)) — zero parsing em runtime, o canal fica baked no IR.
import type { PathRouter } from '@statedelta-libs/expressions';
const pathRouter: PathRouter = (path) => {
if (path.charCodeAt(0) === 36) return "env"; // "$tickEnv"
if (path.includes(":")) return "state"; // "hp:value"
return null; // → campo direto do data
};
const compiler = new ExpressionCompiler({ scope, pathRouter });
// JIT: cada canal é um param flat → acc_state("hp:value") > acc_env("$tickEnv")
const artifact = compiler.jit({ /* ... refs com "hp:value", "$tickEnv" ... */ });
// Bind os accessors do canal — trocáveis por ctx/tick, O(1), sem recompilar
artifact.bind({ scope, accessors: { state: ctx.get.bind(ctx), env: (k) => env[k.slice(1)] } });| | single accessor | pathRouter + accessors |
|---|---|---|
| DSL | { $: "hp:value" } | { $: "hp:value" } (idem) |
| Roteamento | tudo → canal main | router(path) em compile-time → canal |
| Código gerado | accessor("hp:value") | acc_state("hp:value") (fn dedicada por canal) |
| Runtime | você faz split/dispatch a cada chamada | zero parsing — chamada direta |
| router → null | — | resolve como campo direto do data |
| bind() | ctx.accessor (= canal main) | ctx.accessors (mapa canal → fn); ctx.accessor ainda vale como main |
| Param flat (JIT) | accessor | acc_<canal> (accessor para main) |
accessor é sugar para accessors: { main: fn }. Nomes de canal viram identificadores — mantenha-os em [A-Za-z0-9_], mesma restrição dos namespaces de handler. Sem pathRouter, comportamento idêntico ao single accessor. Zero overhead quando nenhum accessor/router é configurado.
Documentação
| Doc | Conteúdo | |-----|----------| | EXPRESSIONS-API.md | Referência completa da API pública | | docs/ARCHITECTURE.md | Arquitetura interna (IR, backends, cache) | | docs/TEMPLATE.md | Template compiler (compileDefinition) |
Licença
MIT © Anderson D. Rosa
