lib/transformations — JSONata sandboxed engine
Fase 5 — Motor real de expresiones para computed mappings y
submission.conditions[]. Reemplaza los stubs que antes retornaban el
literal ƒ(${expr}).
Arquitectura en 30 segundos
save (POST/PUT) runtime (wrapper executor)
─────────────── ──────────────────────────
validateActionTypeExpressions buildInputContext
│ │
├─ compile(expr, scope) ←─────┤
│ · parser JSONata │
│ · AST analysis │
│ · inference (best-effort) │
│ · cache in-memory │
↓ ↓
expression_cache evaluate(compiled, context, opts)
(persiste en action_types) · runWithTimeout 100ms
· assertInputSize 1MB
· assertOutputSize 256KB
· classify: success | error | timeout
↓
expression_trace
(persiste en action_type_executions)
Los 7 archivos
| File | Responsabilidad |
|---|---|
types.ts | Contratos compartidos (ExpressionScope, CompileResult, EvaluateResult, SourceSchema). |
sandbox.ts | runWithTimeout + assert(Input/Output)Size + ExpressionTimeoutError / ExpressionSizeError. |
context.ts | buildInputContext / buildOutputContext / buildConditionContext — whitelist frozen. |
type-inference.ts | AST walker + tabla de 40+ built-ins JSONata tipados. inferType best-effort. |
compiler.ts | compile() — parse + validate + cache. Never throws. Cap MAX_COMPLEXITY=500. |
engine.ts | evaluate() — orquesta sandbox + JSONata. Classifica en success/error/timeout. |
validate-config.ts | validateActionTypeExpressions() — bulk validator usado en save. |
monaco-jsonata.ts | Registración del lenguaje en Monaco + completion provider + temas node. |
Barrel: @/lib/transformations exporta todo lo público.
Contratos never-throws
// compile — devuelve discriminated union
const result = compile({ expression, scope, sourceSchema });
if (result.status === 'broken') return 400;
// result._compiled listo para evaluate()
// evaluate — clasifica en 3 buckets
const outcome = await evaluate({ compiled, context, opts });
switch (outcome.status) {
case 'success': use(outcome.value); break;
case 'error': log(outcome.error); break;
case 'timeout': bail(); break;
}
Sandboxing — las 3 capas
| Capa | Cap | Cuándo |
|---|---|---|
| Complexity | 500 AST nodos | Save-time (compile) |
| Input size | 1MB | Runtime (pre-eval) |
| Timeout | 100ms runtime / 1000ms preview | Runtime (durante eval) |
| Output size | 256KB | Runtime (post-eval) |
JSONata es un DSL cerrado — no tiene eval, Function, require,
process, global. No necesitamos vm2/isolated-vm. La única barrera
real es el timeout (cooperativo via Promise.race).
Contexts whitelist
| Scope | Namespaces |
|---|---|
input | upstream, params, user{id,role,email}, workspace{id}, now |
output | item, index, response, user, workspace, now |
condition | user, workspace, params, upstream, now |
Todo Object.freeze recursivo. JSONata no puede mutar nada ni
acceder fuera del whitelist.
Type inference (best-effort)
- Literales:
number|string|boolean|null - Binarias:
+-*/%→ number;=/!=/<=/>=/and/or/in→ boolean;&→ string - Built-ins: tabla de 40+ (ver
FUNCTION_TYPESentype-inference.ts) - Paths: contra
SourceSchema.namespaces[ns][field]si se pasa; sinounknown
Mismatch con targetType → soft warning, nunca hard fail.
Runtime aplica coerción donde posible.
Integración end-to-end
| Consumer | Llamadas |
|---|---|
POST/PUT /api/actiontypes | validateActionTypeExpressions() → persiste expression_cache |
| Wrapper executor | compile() + evaluate() per-row en applyInputMapping / applyOutputMapping |
Guard (canExecuteActionType) | compile() + evaluate() per-condition tras RPC OK |
/expressions/validate | compile() headless |
/expressions/preview | compile() + evaluate() contra sample |
| UI editor Monaco | compile() debounced para live validation |
Misma librería en save, runtime, UI, headless — zero drift entre qué valida el servidor y qué autocompleta el editor.
Caches
compiler.ts→Map<(scope,expression), CompiledExpression>. Per-process, no LRU. Reset viainvalidateCompilerCache(). En un run con N items que comparten la misma expresión: 1 parse + N evals. En serverless cold-start: 1 parse per request.
Observabilidad
- Save-time events:
expression.validated/expression.validation_failed(phases canónicas). - Runtime events:
expression.evaluated/expression.failed(agregado summary por run). - Trace column:
action_type_executions.expression_trace— per-eval detail. - Cache column:
action_types.expression_cache— per-action snapshot. - Health block:
/api/observability/health→expressions(24h stats + top slowest + broken count).
Queries SQL útiles
-- Top action_types con más evals en 24h
SELECT action_type_id, SUM((expression_trace->>'evalCount')::int) AS evals
FROM action_type_executions
WHERE expression_trace IS NOT NULL
AND created_at > NOW() - INTERVAL '24 hours'
GROUP BY 1 ORDER BY evals DESC LIMIT 10;
-- Runs con timeouts en la última hora
SELECT id, action_type_id,
(expression_trace->>'timeoutCount')::int AS timeouts,
(expression_trace->>'totalMs')::int AS total_ms
FROM action_type_executions
WHERE (expression_trace->>'timeoutCount')::int > 0
AND created_at > NOW() - INTERVAL '1 hour';
-- Action_types con expresiones inválidas
SELECT id, name, (expression_cache->>'brokenCount')::int AS broken
FROM action_types
WHERE (expression_cache->>'brokenCount')::int > 0;
Lo que NO hace Fase 5
- Approval flows (
requireApprovalstub) → Fase 6. - Execution chains coordinados → Fase 6.
- Runtime isolation con worker_threads — cooperative timeout es suficiente
dado
MAX_COMPLEXITY+ input caps. - UI de conditions editor —
submission.conditions[]hoy se edita vía API bruta. La UI Monaco-powered para conditions queda como follow-up menor. - Streaming tier eval tracking — Tier 2 (streaming batches) hoy no registra
en
expression_trace(solo Tier 1 inline). Extensión trivial para post-5.