ic_fluent_resize_20_filledCreated with Sketch.
ic_fluent_resize_20_filledCreated with Sketch.
Skip to content

Codegen diagrams

Yantrix Runtime Event Flow

ic_fluent_resize_20_filledCreated with Sketch.

Figure 1: This diagram shows the runtime event flow in Yantrix: external events are dispatched to the EventBus, processed by the CoreLoop through the EventAdapter into actions, reduced by the generated automaton and its RootReducer, and finally emitted to destinations such as UI or external I/O

Yantrix Code Generation Pipeline

ic_fluent_resize_20_filledCreated with Sketch.

Figure 2: This diagram illustrates the code generation pipeline in Yantrix: the CLI reads a Mermaid state diagram and Yantrix notes, parses them with mermaid-parser and YantrixParser, runs the codegen module to build dictionaries, reducers and the automaton class, and finally writes the generated automaton code to a file

Reducer compilation details

This section explains in detail how reducer functions for each state are compiled from Yantrix notes into JavaScript/TypeScript code. All the logic described here corresponds to the implementation in packages/codegen/src/core/modules/JavaScript/JavaScriptCompiler/context/serializer.ts.

1. Overall goal

The contextSerializer is responsible for generating two main pieces of runtime code:

  • const reducer = { [stateId]: (prevContext, payload, functionDictionary, automata) => newContext }
  • const getDefaultContext = (prevContext, payload) => { ... }

These are later embedded into the generated automata class and are used at runtime to compute the next context for each state transition.

The key exported helpers are:

  • getStateReducerCode – builds the reducer object.
  • getStateToContext – generates per-state reducer functions.
  • getContextTransition – computes the context expression for a given state.
  • getContextItem – converts a single contextDescription block into key: expression pairs.
  • mapReducerItems – converts reducer rows into right-hand-side expressions.
  • getBoundValues – binds intermediate values to final context properties, with fallbacks.
  • getDefaultContext – generates the default context constructor based on StartState.

2. From getStateReducerCode to getContextTransition

At the top level, getStateReducerCode produces the reducer definition:

ts
function getStateReducerCode(props) {
  return `const reducer = {
    ${getStateToContext(props).join(',\n\t')}
  }`;
}

getStateToContext walks all states in the diagram and creates one reducer function per state:

ts
function getStateToContext(props) {
  return props.diagram.states.map((state) => {
    const stateValue = props.stateDictionary.getStateValues({ keys: [state.id] })[0];

    if (!stateValue) {
      throw new Error('Invalid state');
    }

    return `${stateValue}: (prevContext, payload, functionDictionary, automata) => {

      return ${getContextTransition({
        value: stateValue,
        stateDictionary: props.stateDictionary,
        diagram: props.diagram,
        expressions: props.expressions,
      })}
    }`;
  });
}

For each logical state:

  • it looks up the numeric stateValue from BasicStateDictionary by state.id;
  • it then delegates to getContextTransition(...), which returns a string expression representing the new context for that state:
    • either "prevContext" (identity),
    • or an object literal string like { foo: ..., bar: ... }.

The result is a reducer object of the form:

ts
const reducer = {
  1: (prevContext, payload, functionDictionary, automata) => {
    return { /* compiled context for state 1 */ };
  },
  2: (prevContext, payload, functionDictionary, automata) => {
    return prevContext;
  },
  // ...
};

getContextTransition is the entry point for computing the per-state context expression:

ts
function getContextTransition(props) {
  const stateFromDict = props.stateDictionary.getStateKeys({ states: [props.value] })[0];

  if (stateFromDict === null) {
    throw new Error(`Invalid state - ${props.value}`);
  }

  const diagramState = props.diagram.states.find((diagramState) => {
    return diagramState.id === stateFromDict;
  });

  if (!diagramState) {
    throw new Error(`Invalid state - ${props.value}`);
  }

  const ctxRes: string[] = [];

  diagramState.notes?.contextDescription.forEach((ctx) => {
    const newContext = getContextItem({
      ctx,
      expressions: props.expressions,
    });

    ctxRes.push(...newContext);
  });

  if (ctxRes.length === 0) return 'prevContext';

  return `{${ctxRes.join(',\n\t')}}`;
};

Steps:

  1. Reverse-lookup stateFromDict from the numeric value using stateDictionary.getStateKeys.
  2. Find the corresponding diagramState in diagram.states.
  3. For each ctx in diagramState.notes?.contextDescription, call getContextItem to obtain a list of "key: expression" strings and accumulate them in ctxRes.
  4. If there is no context description (ctxRes.length === 0), return 'prevContext' so the reducer becomes an identity function.
  5. Otherwise, wrap all context entries into an object literal: `{${ctxRes.join(',\n\t')}}`.

The final reducer function for a state then effectively looks like:

ts
stateValue: (prevContext, payload, functionDictionary, automata) => {
  return {
    foo: (function(){ ... }()),
    bar: (function(){ ... }()),
  };
}

3. getContextItem: with and without reducer blocks

getContextItem is responsible for transforming a single TContextItem (one block from notes.contextDescription) into an array of "key: expression" strings:

ts
function getContextItem(props: { ctx: TContextItem; expressions: TExpressionRecord; }) {
  if (isContextWithReducer(props.ctx)) {
    const { context, reducer } = props.ctx;

    return getBoundValues({
      expressions: props.expressions,
      arr: mapReducerItems({ reducer, expressions: props.expressions }),
      context,
    });
  } else {
    const { context } = props.ctx;
    return context.map(({ keyItem }) => {
      const { identifier } = keyItem;
      if (isKeyItemWithExpression(keyItem)) {
        const expressionValue = expressions.functions.getExpressionValue({
          expression: keyItem.expression,
          expressionRecord: props.expressions,
        });

        return `${identifier}: ${expressions.serializer.getDefaultPropertyContext('prevContext', identifier, expressionValue)}`;
      } else {
        return `${identifier}: ${expressions.serializer.getDefaultPropertyContext('prevContext', identifier)}`;
      }
    });
  }
};

There are two major branches:

  1. isContextWithReducer(ctx) is true:

    • This means the context block declares an explicit reducer section in Yantrix notes.

    • The flow is:

      • mapReducerItems({ reducer, expressions }) – converts each reducer row into a right-hand-side expression string.
      • getBoundValues({ arr, context, expressions }) – zips these expressions with the context definitions to produce final "targetProperty: expression" pairs.
    • The output is an array of strings like:

      ts
      [
        "foo: (function(){ const boundValue = ...; return boundValue; }())",
        "bar: (function(){ const boundValue = ...; if(boundValue !== null) return boundValue; else return <fallback>; }())",
      ]
  2. No reducer in ctx:

    • This is the simpler form, where each context entry only defines a target identifier (and maybe an expression for default value).
    • For each keyItem in context:
      • If it has an expression:

        ts
        const expressionValue = expressions.functions.getExpressionValue({
          expression: keyItem.expression,
          expressionRecord: props.expressions,
        });
        
        return `${identifier}: ${expressions.serializer.getDefaultPropertyContext('prevContext', identifier, expressionValue)}`;

        Here:

        • getExpressionValue turns the Yantrix expression into a JS snippet.
        • getDefaultPropertyContext('prevContext', identifier, expressionValue) generates code that prefers prevContext[identifier], but falls back to expressionValue.
      • If there is no expression:

        ts
        return `${identifier}: ${expressions.serializer.getDefaultPropertyContext('prevContext', identifier)}`;

        In this case the value is fully taken from prevContext[identifier] (or some default inside getDefaultPropertyContext).

In summary, getContextItem returns an array of "key: expression" strings, either driven by an explicit reducer or by simple context defaults.

4. mapReducerItems: compiling reducer rows

mapReducerItems takes a reducer: TKeyItems<'reducer'> and produces an array of raw expressions (arr: string[]), which are later bound to target context properties by getBoundValues:

ts
function mapReducerItems(props: {
  reducer: TKeyItems<'reducer'>;
  sourcePath?: string;
  expressions: TExpressionRecord;
}) {
  return props.reducer
    .map(({ keyItem }) => {
      if (isKeyItemReference(keyItem)) {
        const { expressionType, identifier: boundIdentifier } = keyItem;
        const path = props.sourcePath ?? pathRecord[expressionType];

        if (keyItem.expressionType === ExpressionTypes.Constant) {
          const expressionValueRight = expressions.functions.getExpressionValue({
            expression: keyItem,
            expressionRecord: props.expressions,
          });
          return `(function(){
            return ${expressionValueRight}
          }())`;
        }

        if (isKeyItemWithExpression(keyItem)) {
          const { expression } = keyItem;

          const expressionValueRight = expressions.functions.getExpressionValue({
            expression,
            expressionRecord: props.expressions,
          });

          return expressions.serializer.getDefaultPropertyContext(path, boundIdentifier, expressionValueRight);
        }

        return expressions.serializer.getDefaultPropertyContext(path, boundIdentifier);
      } else {
        const { expression } = keyItem;

        const expressionValueRight = expressions.functions.getExpressionValue({
          expression,
          expressionRecord: props.expressions,
        });
        return `(function(){
          return ${expressionValueRight}
        }())`;
      }
    });
}

Key branches:

  • isKeyItemReference(keyItem) is true:

    • Indicates that the row refers to some source path (context, payload, constants, etc.) and binds it to an identifier.
    • expressionType (from ExpressionTypes) determines which path to use via pathRecord[expressionType].
    1. expressionType === Constant:

      ts
      const expressionValueRight = expressions.functions.getExpressionValue({
        expression: keyItem,
        expressionRecord: props.expressions,
      });
      return `(function(){
        return ${expressionValueRight}
      }())`;
      • getExpressionValue resolves the constant reference (e.g. %%FOO) into a JS snippet, such as CONSTANTS.FOO.
      • The result is wrapped in an IIFE so it becomes an evaluated value at runtime.
    2. isKeyItemWithExpression(keyItem) is true:

      ts
      const expressionValueRight = expressions.functions.getExpressionValue({
        expression,
        expressionRecord: props.expressions,
      });
      
      return expressions.serializer.getDefaultPropertyContext(path, boundIdentifier, expressionValueRight);
      • Builds an expression that first tries path[boundIdentifier], then falls back to the explicit expressionValueRight.
    3. No expression on the keyItem:

      ts
      return expressions.serializer.getDefaultPropertyContext(path, boundIdentifier);
      • Just uses path and boundIdentifier (e.g. context.foo, payload.bar).
  • isKeyItemReference(keyItem) is false:

    • The row is a plain expression not tied to a specific source path.
    ts
    const expressionValueRight = expressions.functions.getExpressionValue({
      expression,
      expressionRecord: props.expressions,
    });
    return `(function(){
      return ${expressionValueRight}
    }())`;

The result of mapReducerItems is arr: string[], where each element is a right-hand-side expression that computes some intermediate value. These expressions are not yet associated with final context properties; that is handled next.

5. getBoundValues: zipping values to context properties

getBoundValues takes:

  • arr – the array of expressions from mapReducerItems.
  • context – the target context description (with keyItem.identifier for each property).

It produces final "targetProperty: expression" strings:

ts
function getBoundValues(props: {
  expressions: TExpressionRecord;
  arr: string[];
  context: any;
}) {
  return props.arr
    .map((el, index) => {
      const item = props.context[index];
      if (!item) {
        throw new Error('Unexpected index bound property');
      }
      const { keyItem } = item;
      const { identifier: targetProperty } = keyItem;

      if (isKeyItemWithExpression(keyItem)) {
        const { expression } = keyItem;

        const expressionValueRight = expressions.functions.getExpressionValue({
          expression,
          expressionRecord: props.expressions,
        });

        return `${targetProperty}: (function(){
          const boundValue = ${el}
          if(boundValue !== null){
            return boundValue
          }
          else {
            return ${expressionValueRight}
          }

        }())`;
      } else {
        return `${targetProperty}: (function(){
          const boundValue = ${el}

          return boundValue

        }())`;
      }
    });
}

Logic:

  1. It iterates over arr with an index and finds the corresponding context[index] entry.

    • If there is no matching context item, it throws an error (defensive check).
  2. Extracts the targetProperty name from keyItem.identifier.

  3. If keyItem has its own expression:

    • It builds a fallback value with getExpressionValue.

    • It generates a final expression:

      ts
      targetProperty: (function(){
        const boundValue = <arr[index]>;
        if(boundValue !== null){
          return boundValue
        }
        else {
          return <expressionValueRight>
        }
      }())
    • This means: prefer the intermediate boundValue (from mapReducerItems), but if it is null, fall back to the local expression defined on the context key.

  4. If keyItem has no expression:

    • The final expression simply returns the intermediate boundValue:

      ts
      targetProperty: (function(){
        const boundValue = <arr[index]>;
        return boundValue;
      }())

This step is where the intermediate expressions produced by mapReducerItems are bound to their final context properties, using the positional pairing between reducer rows and context items.

6. getDefaultContext: initial context from StartState

The getDefaultContext function is generated based on the context description for StartState:

ts
function getDefaultContext(props) {
  const state = props.stateDictionary.getStateValues({ keys: [StartState] })[0];

  if (state) {
    const ctx = getContextTransition({
      diagram: props.diagram,
      expressions: props.expressions,
      stateDictionary: props.stateDictionary,
      value: state,
    });

    return `const getDefaultContext = (prevContext, payload) => {
      const ctx = ${ctx}
      return  Object.assign({}, prevContext, ctx);
    }
    `;
  }

  return `const getDefaultContext = (prevContext, payload) => {
    return prevContext
  }`;
}
  • It resolves the numeric state id for StartState from BasicStateDictionary.

  • If present, it reuses getContextTransition to compute the context expression for the start state.

  • It then generates:

    ts
    const getDefaultContext = (prevContext, payload) => {
      const ctx = <ctxExprForStartState>;
      return Object.assign({}, prevContext, ctx);
    };
  • If no start state is found, getDefaultContext is a simple identity function that returns prevContext unchanged.

7. Role of TExpressionRecord and getExpressionValue

TExpressionRecord (provided by ../expressions) is a registry that knows how to:

  • interpret different kinds of Yantrix expressions (context references, payload references, constants, function calls, etc.),
  • serialize these expressions into JavaScript code snippets,
  • provide helpers such as getDefaultPropertyContext.

The most important API from this record, used in serializer.ts, is:

ts
expressions.functions.getExpressionValue({
  expression,
  expressionRecord: props.expressions,
});

This function:

  • takes a high-level Yantrix expression node,
  • uses expressionRecord to dispatch to the correct handler,
  • returns a string representing the JavaScript code that must be inserted into the generated output.

expressions.serializer.getDefaultPropertyContext(...) complements this by generating higher-level patterns that combine access to a source object with optional fallback expressions.

Together, getExpressionValue and the serializer helpers allow the reducer compiler to stay declarative: it does not hardcode the shape of every expression; instead it relies on TExpressionRecord to generate the exact JavaScript code for each case.

This is the complete flow of how textual Yantrix reducer declarations in diagram notes become executable JavaScript/TypeScript reducer functions in the generated automata class.