diff --git a/src/asl/asl-graph.ts b/src/asl/asl-graph.ts new file mode 100644 index 00000000..e3037fa1 --- /dev/null +++ b/src/asl/asl-graph.ts @@ -0,0 +1,1197 @@ +import { assertNever } from "../assert"; +import { SynthError, ErrorCodes } from "../error-code"; +import { FunctionlessNode } from "../node"; +import { anyOf, invertBinaryOperator } from "../util"; +import { + isState, + isChoiceState, + isFailState, + isSucceedState, + isWaitState, + isTaskState, + isParallelTaskState, + isMapTaskState, + isPassState, + States, + CommonFields, + Task, + State, + Pass, + Condition, +} from "./states"; +import { ASL } from "./synth"; + +/** + * ASL Graph is an intermediate API used to represent a nested and dynamic ASL State Machine Graph. + * + * Unlike ASL, the ASL graph supports nested nodes, can associate nodes to {@link FunctionlessNode}s, and contains a representation of the output of a state. + * + * ASL Graph is completely stateless. + */ +export namespace ASLGraph { + /** + * Used by integrations as a placeholder for the "Next" property of a task. + * + * When task.Next is ASLGraph.DeferNext, Functionless will replace the Next with the appropriate value. + * It may also add End or ResultPath based on the scenario. + */ + export const DeferNext: string = "__DeferNext"; + + export function isSubState( + state: ASLGraph.NodeState | ASLGraph.SubState | ASLGraph.NodeResults + ): state is ASLGraph.SubState { + return state && "startState" in state; + } + + /** + * A Sub-State is a collection of possible return values. + * A start state is the first state in the result. It will take on the name of the parent statement node. + * States are zero to many named states or sub-stages that will take on the name of the parent statement node. + */ + export interface SubState { + startState: string; + node?: FunctionlessNode; + states: { [stateName: string]: ASLGraph.NodeState | ASLGraph.SubState }; + } + + export const isStateOrSubState = anyOf(isState, ASLGraph.isSubState); + + /** + * An {@link ASLGraph} interface which adds an optional {@link FunctionlessNode} to a state. + * + * The node is used to name the state. + */ + export type NodeState = S & { + node?: FunctionlessNode; + }; + + /** + * The possible outputs of evaluating an {@link Expr}. + * + * State - A state with an {@link ASLGraph.Output} and optional {@link FunctionlessNode} + * SubStates - A sub-state graph with an {@link ASLGraph.Output} and optional {@link FunctionlessNode} + * JsonPath - a JSON Path Variable Reference, the consumer should use where json path is valid, ignore, or fail. + * Value - a Value of type number, string, boolean, object, or null. Consumers should use where values can be used or turn into JsonPath using a {@link Pass} state. + */ + export type NodeResults = + | ASLGraph.OutputState + | ASLGraph.OutputSubState + | ASLGraph.Output; + + /** + * A compound state is a state node that may contain a simple Constant or Variable output instead of + * built states or sub-states. + * + * Compound states are designed to be incorporated into existing states or turned into + * states before they are returned up. + * + * Compound states cannot be nested in sub-states. + */ + export interface OutputSubState extends ASLGraph.SubState { + output: ASLGraph.Output; + } + + /** + * An {@link ASLGraph} interface which adds an {@link ASLGraph.Output} a state. + * + * The node is used to name the state. + */ + export type OutputState = NodeState & { + output: ASLGraph.Output; + }; + + export function isOutputStateOrSubState( + state: any + ): state is ASLGraph.OutputSubState | ASLGraph.OutputState { + return "output" in state; + } + + /** + * A literal value of type string, number, boolean, object, or null. + * + * If this is an Object, the object may contain nested JsonPaths as denoted by `containsJsonPath`. + */ + export interface LiteralValue { + /** + * Whether there is json path in the constant. + * + * Helps determine where this constant can go for validation and + * when false use Result in a Pass State instead of Parameters + */ + containsJsonPath: boolean; + value: string | number | null | boolean | Record | any[]; + } + + /** + * A json path based state values reference. + */ + export interface JsonPath { + jsonPath: string; + } + + export interface ConditionOutput { + condition: Condition; + } + + export type Output = + | ASLGraph.LiteralValue + | ASLGraph.JsonPath + | ASLGraph.ConditionOutput; + + export function isLiteralValue(state: any): state is ASLGraph.LiteralValue { + return "value" in state; + } + + export function isJsonPath(state: any): state is ASLGraph.JsonPath { + return "jsonPath" in state; + } + + export function isConditionOutput( + state: any + ): state is ASLGraph.ConditionOutput { + return "condition" in state; + } + + export const isAslGraphOutput = anyOf( + isLiteralValue, + isJsonPath, + isConditionOutput + ); + + /** + * Wires together an array of {@link State} or {@link ASLGraph.SubState} nodes in the order given. + * Any state which is missing Next/End will be given a Next value of the next state with the final state + * either being left as is. + */ + export function joinSubStates( + node?: FunctionlessNode, + ...subStates: ( + | ASLGraph.NodeState + | ASLGraph.SubState + | ASLGraph.NodeResults + | undefined + )[] + ): ASLGraph.SubState | ASLGraph.NodeState | undefined { + if (subStates.length === 0) { + return undefined; + } + + const realStates = subStates + .filter((x) => !!x) + .filter(ASLGraph.isStateOrSubState); + return realStates.length === 0 + ? undefined + : realStates.length === 1 + ? { node, ...realStates[0]! } + : { + startState: "0", + node, + states: Object.fromEntries( + realStates.map((subState, i) => { + return [ + `${i}`, + i === realStates.length - 1 + ? subState + : updateDeferredNextStates({ Next: `${i + 1}` }, subState), + ]; + }) + ), + }; + } + + /** + * Used to lazily provide the next step to a provided state or nested set of states. + * + * Recursively traverse sub-states down to regular states, replacing any + * nodes with `Next: ASLGraph.DeferNext` or `Next: undefined` with the given props. + * + * Note: States without `Next` are ignored and {@link Map} states replace Default and `Choices[].Next` instead. + */ + export function updateDeferredNextStates( + props: + | { + End: true; + } + | { + Next: string; + }, + state: T + ): T { + return ASLGraph.isSubState(state) + ? updateDeferredNextSubStates>(props, state) + : (updateDeferredNextState(props, state) as T); + } + + /** + * Updates DeferNext states for an entire sub-state. + */ + function updateDeferredNextSubStates( + props: + | { + End: true; + } + | { + Next: string; + }, + subState: T + ): T { + // address the next state as a level up to keep the name unique. + const updatedProps = + "Next" in props && props.Next + ? { + ...props, + Next: `../${props.Next}`, + } + : props; + return { + ...subState, + states: Object.fromEntries( + Object.entries(subState.states ?? {}).map(([id, state]) => { + return [id, updateDeferredNextStates(updatedProps, state)]; + }) + ), + }; + } + + /** + * Step functions can fail to deploy when extraneous properties are left on state nodes. + * Only inject the properties the state type can handle. + * + * For example: https://github.com/functionless/functionless/issues/308 + * A Wait state with `ResultPath: null` was failing to deploy. + */ + function updateDeferredNextState( + props: + | { + End: true; + } + | { + Next: string; + }, + state: T + ): T { + const [End, Next] = + "End" in props ? [props.End, undefined] : [undefined, props.Next]; + + if (isChoiceState(state)) { + return { + ...state, + Choices: state.Choices.map((choice) => ({ + ...choice, + Next: choice.Next === ASLGraph.DeferNext ? Next! : choice.Next, + })), + Default: state.Default === ASLGraph.DeferNext ? Next : state.Default, + }; + } else if (isFailState(state) || isSucceedState(state)) { + return state; + } else if (isWaitState(state)) { + return { + ...state, + End: state.Next === ASLGraph.DeferNext ? End : state.End, + Next: state.Next === ASLGraph.DeferNext ? Next : state.Next, + } as T; + } else if ( + isTaskState(state) || + isParallelTaskState(state) || + isMapTaskState(state) + ) { + return { + ...state, + Catch: state.Catch + ? state.Catch.map((_catch) => ({ + ..._catch, + Next: _catch.Next === ASLGraph.DeferNext ? Next : _catch.Next, + })) + : undefined, + End: state.Next === ASLGraph.DeferNext ? End : state.End, + Next: state.Next === ASLGraph.DeferNext ? Next : state.Next, + } as T; + } else if (isPassState(state)) { + return { + ...state, + End: state.Next === ASLGraph.DeferNext ? End : state.End, + Next: state.Next === ASLGraph.DeferNext ? Next : state.Next, + }; + } + assertNever(state); + } + + /** + * Helper which can update a Asl state to a new output. + * Sometimes the output can be updated in places like when accessing a constant or variable. + * + * If the state is a compound state, only the output needs to change, not the states it contains. + * + * ```ts + * const obj = { a: { b: 1 } }; + * return obj.a.b; + * ``` + * + * output of obj.a + * { startState: ..., states: {...}, output: { jsonPath: "$.obj.a" } } + * + * output of obj.a.b + * { startState: ..., states: {...}, output: { jsonPath: "$.obj.a.b" } } + * + * Only the jsonPath has been mutated because no one used use intermediate output. + */ + export function updateAslStateOutput( + state: ASLGraph.NodeResults, + newOutput: ASLGraph.Output + ) { + if (ASLGraph.isOutputStateOrSubState(state)) { + return { + ...state, + output: newOutput, + }; + } + return newOutput; + } + + /** + * Key map for re-writing relative state names to absolute + */ + interface NameMap { + parent?: NameMap; + localNames: Record; + } + + /** + * Transforms an {@link ASLGraph.AslState} or {@link ASLGraph.SubState} into a ASL {@link States} collection of flat states. + * + * Uses the parent name as a starting point. All state nodes of sub-states will be given the name of their parent. + * + * Sub-States with local or relative state references will be rewritten to the updated parent state name. + * + * Removes unreachable states from the graph. Unreachable states will cause step functions to fail. + * + * sub state + * ```ts + * { + * startState: "default", + * states: { + * default: { Next: 'b' }, + * b: { Next: 'c' }, + * c: { Next: 'externalState' } + * } + * } + * ``` + * + * Parent state name: parentState + * + * rewrite + * ```ts + * { + * parentState: { Next: 'b__parentState' }, + * b__parentState: { Next: 'c__parentState' }, + * c__parentState: { Next: 'externalState' } + * } + * ``` + * + * Local State Names + * + * In the below example, default, b, and c are all local state names. + * + * ```ts + * { + * startState: "default", + * states: { + * default: { Next: 'b' }, + * b: { Next: 'c' }, + * c: { Next: 'externalState' } + * } + * } + * ``` + * + * Relative state names + * + * Path structures can be used to denote relative paths. ex: `../stateName`. + * + * ```ts + * { + * startState: "default", + * states: { + * default: { Next: 'b' }, + * b: { + * startState: "start", + * states: { + * start: { + * Next: "../c" + * } + * } + * }, + * c: { Next: 'externalState' } + * } + * } + * ``` + * + * In the above example, b/start's next state is c in it's parent state. + * + * Currently referencing child states (ex: `./b/start`) is not supported. + * + * All state names not found in local or parent sub-states will be assumed to be top level state names and will not be re-written. + */ + export function toStates( + startState: string, + states: + | ASLGraph.NodeState + | ASLGraph.SubState + | ASLGraph.OutputState + | ASLGraph.OutputSubState, + getStateNames: ( + parentName: string, + states: ASLGraph.SubState + ) => Record + ): States { + const namedStates = internal(startState, states, { localNames: {} }); + + /** + * Find any choice states that can be joined with their target state. + * TODO: generalize the optimization statements. + */ + const updatedStates = joinChainedChoices( + startState, + /** + * Remove any states with no effect (Pass, generally) + * The incoming states to the empty states are re-wired to the outgoing transition of the empty state. + */ + removeEmptyStates(startState, namedStates) + ); + + const reachableStates = findReachableStates(startState, updatedStates); + + // only take the reachable states + return Object.fromEntries( + Object.entries(updatedStates).filter(([name]) => + reachableStates.has(name) + ) + ); + + function internal( + parentName: string, + states: + | ASLGraph.NodeState + | ASLGraph.SubState + | ASLGraph.OutputState + | ASLGraph.OutputSubState, + stateNameMap: NameMap + ): [string, State][] { + if (!states) { + return []; + } else if (!ASLGraph.isSubState(states)) { + // strip output and node off of the state object. + const { node, output, ...updated } = ( + rewriteStateTransitions(states, stateNameMap) + ); + return [[parentName, updated]]; + } else { + const nameMap: NameMap = { + parent: stateNameMap, + localNames: getStateNames(parentName, states), + }; + return Object.entries(states.states).flatMap(([key, state]) => { + const parentName = nameMap.localNames[key]; + if (!parentName) { + throw new SynthError( + ErrorCodes.Unexpected_Error, + `Expected all local state names to be provided with a parent name, found ${key}` + ); + } + return internal(parentName, state, nameMap); + }); + } + } + } + + /** + * Given a directed adjacency matrix, return a `Set` of all reachable states from the start state. + */ + function findReachableStates( + startState: string, + states: Record + ) { + const visited = new Set(); + + // starting from the start state, find all reachable states + depthFirst(startState); + + return visited; + + function depthFirst(stateName: string) { + if (visited.has(stateName)) return; + visited.add(stateName); + const state = states[stateName]!; + visitTransition(state, depthFirst); + } + } + + function removeEmptyStates( + startState: string, + stateEntries: [string, State][] + ): [string, State][] { + /** + * Find all {@link Pass} states that do not do anything. + */ + const emptyStates = Object.fromEntries( + stateEntries.filter((entry): entry is [string, Pass] => { + const [name, state] = entry; + return ( + name !== startState && + isPassState(state) && + !!state.Next && + !( + state.End || + state.InputPath || + state.OutputPath || + state.Parameters || + state.Result || + state.ResultPath + ) + ); + }) + ); + + const emptyTransitions = computeEmptyStateToUpdatedTransition(emptyStates); + + // return the updated set of name to state. + return stateEntries.flatMap(([name, state]) => { + if (name in emptyTransitions) { + return []; + } + + return [ + [ + name, + visitTransition(state, (transition) => + transition in emptyTransitions + ? emptyTransitions[transition]! + : transition + ), + ], + ]; + }); + + /** + * Find the updated next value for all of the empty states. + * If the updated Next cannot be determined, do not remove the state. + */ + function computeEmptyStateToUpdatedTransition( + emptyStates: Record + ) { + return Object.fromEntries( + Object.entries(emptyStates).flatMap(([name, { Next }]) => { + const newNext = Next ? getNext(Next, []) : Next; + + /** + * If the updated Next value for this state cannot be determined, + * do not remove the state. + * + * This can because the state has no Next value (Functionless bug) + * or because all of the states in a cycle are empty. + */ + if (!newNext) { + return []; + } + + return [[name, newNext]]; + + /** + * When all states in a cycle are empty, the cycle will be impossible to exit. + * + * Note: This should be a rare case and is not an attempt to find any non-terminating logic. + * ex: `for(;;){}` + * Adding most conditions, incrementors, or bodies will not run into this issue. + * + * ```ts + * { + * 1: { Type: "???", Next: 2 }, + * 2: { Type: "Pass", Next: 3 }, + * 3: { Type: "Pass", Next: 4 }, + * 4: { Type: "Pass", Next: 2 } + * } + * ``` + * + * State 1 is any state that transitions to state 2. + * State 2 transitions to empty state 3 + * State 3 transitions to empty state 4 + * State 4 transitions back to empty state 2. + * + * Empty Pass states provide no value and will be removed. + * Empty Pass states can never fail and no factor can change where it goes. + * + * This is not an issue for other states which may fail or inject other logic to change the next state. + * Even the Wait stat could be used in an infinite loop if the machine is terminated from external source. + * + * If this happens, return undefined. + */ + function getNext( + transition: string, + seen: string[] = [] + ): string | undefined { + if (seen?.includes(transition)) { + return undefined; + } + return transition in emptyStates + ? getNext( + emptyStates[transition]!.Next!, + seen ? [...seen, transition] : [transition] + ) + : transition; + } + }) + ); + } + } + + /** + * A {@link Choice} state that points to another {@link Choice} state can adopt the target state's + * choices and Next without adding an additional transition. + * + * 1 + * if a -> 2 + * if b -> 3 + * else -> 4 + * 2 + * if c -> 3 + * else -> 4 + * 3 - Task + * 4 + * if e -> 5 + * else -> 6 + * 5 - Task + * 6 - Task + * + * => + * + * 1 + * if a && c -> 3 (1 and 2) + * if a && e -> 5 (1 and 4) + * if b -> 3 + * if e -> 5 (4) + * else -> 6 (4's else) + * 2 - remove (if nothing else points to it) + * 3 - Task + * 4 - remove (if nothing else points to it) + * 5 - Task + * 6 - Task + */ + function joinChainedChoices( + startState: string, + stateEntries: [string, State][] + ) { + const stateMap = Object.fromEntries(stateEntries); + + const updatedStates: Record = {}; + + depthFirst(startState); + + // we can assume that all null states have been updated by here. + return updatedStates as Record; + + function depthFirst(state: string): State | null { + if (state in updatedStates) return updatedStates[state]!; + const stateObj = stateMap[state]!; + if (!isChoiceState(stateObj)) { + updatedStates[state] = stateObj; + visitTransition(stateObj, (next) => { + depthFirst(next); + }); + // no change + return stateObj; + } + // set self to null to 1) halt circular references 2) avoid circular merges between choices. + // if #2 happens, that choice will always fail as state cannot change between transitions. + updatedStates[state] = null; + const branches = stateObj.Choices.flatMap((branch) => { + const { Next: branchNext, ...branchCondition } = branch; + const nextState = depthFirst(branchNext); + // next state should only by null when there is a circular reference between choices + if (!nextState || !isChoiceState(nextState)) { + return [branch]; + } else { + const nextBranches = nextState.Choices.map( + ({ Next, ...condition }) => { + // for each branch in the next state, AND with the current branch and assign the next state's Next. + return { ...ASL.and(branchCondition, condition), Next }; + } + ); + return nextState.Default + ? [...nextBranches, { ...branchCondition, Next: nextState.Default }] + : nextBranches; + } + }); + const defaultState = stateObj.Default + ? depthFirst(stateObj.Default) + : undefined; + + const [defaultValue, defaultBranches] = + !defaultState || !isChoiceState(defaultState) + ? [stateObj.Default, []] + : [defaultState.Default, defaultState.Choices]; + + const mergedChoice = { + ...stateObj, + Choices: [...branches, ...defaultBranches], + Default: defaultValue, + }; + + updatedStates[state] = mergedChoice; + return mergedChoice; + } + } + + /** + * Visit each transition in each state. + * Use the callback to update the transition name. + */ + function visitTransition( + state: State, + cb: (next: string) => string | undefined | void + ): State { + const cbOrNext = (next: string) => cb(next) ?? next; + if ("End" in state && state.End !== undefined) { + return state; + } + if (isChoiceState(state)) { + return { + ...state, + Choices: state.Choices.map((choice) => ({ + ...choice, + Next: cbOrNext(choice.Next), + })), + Default: state.Default ? cbOrNext(state.Default) : undefined, + }; + } else if ("Catch" in state) { + return { + ...state, + Catch: state.Catch?.map((_catch) => ({ + ..._catch, + Next: _catch.Next ? cbOrNext(_catch.Next) : _catch.Next, + })), + Next: state.Next ? cbOrNext(state.Next) : state.Next, + }; + } else if (!("Next" in state)) { + return state; + } + return { + ...state, + Next: state.Next ? cbOrNext(state.Next) : state.Next, + }; + } + + /** + * Finds the local state name in the nameMap. + * + * If the name contains the prefix `../` the search will start up a level. + * + * If a name is not found at the current level, the parent names will be searched. + * + * If no local name is found, the next value is returned as is. + */ + function rewriteStateTransitions( + state: ASLGraph.NodeState, + subStateNameMap: NameMap + ) { + return visitTransition(state, (next) => + updateTransition(next, subStateNameMap) + ); + + function updateTransition(next: string, nameMap: NameMap): string { + if (next.startsWith("../")) { + if (nameMap.parent) { + return updateTransition(next.substring(3), nameMap.parent); + } + return next.substring(3); + } else { + const find = (nameMap: NameMap): string => { + if (next in nameMap.localNames) { + return nameMap.localNames[next]!; + } else if (nameMap.parent) { + return find(nameMap.parent); + } else { + return next; + } + }; + return find(nameMap); + } + } + } + + /** + * Normalized an ASL state to just the output (constant or variable). + */ + export function getAslStateOutput( + state: ASLGraph.NodeResults + ): ASLGraph.Output { + return ASLGraph.isAslGraphOutput(state) ? state : state.output; + } + + /** + * Applies an {@link ASLGraph.Output} to a partial {@link Pass}. + * + * {@link ASLGraph.ConditionOutput} must be first turned into a {@link ASLGraph.JsonPath}. + */ + export function passWithInput( + pass: Omit, "Parameters" | "InputPath" | "Result"> & + CommonFields, + value: Exclude + ): Pass { + return { + ...pass, + ...(ASLGraph.isJsonPath(value) + ? { + InputPath: value.jsonPath, + } + : value.containsJsonPath + ? { + Parameters: value.value, + } + : { + Result: value.value, + }), + }; + } + + /** + * Applies an {@link ASLGraph.Output} to a partial {@link Task} + * + * {@link ASLGraph.ConditionOutput} must be first turned into a {@link ASLGraph.JsonPath}. + */ + export function taskWithInput( + task: Omit & CommonFields, + value: Exclude + ): Task { + return { + ...task, + ...(ASLGraph.isJsonPath(value) + ? { + InputPath: value.jsonPath, + } + : { + Parameters: value.value, + }), + }; + } + + /** + * Compare any two {@link ASLGraph.Output} values. + */ + export function compareOutputs( + leftOutput: ASLGraph.Output, + rightOutput: ASLGraph.Output, + operator: ASL.ValueComparisonOperators + ): Condition { + if ( + ASLGraph.isLiteralValue(leftOutput) && + ASLGraph.isLiteralValue(rightOutput) + ) { + return ((operator === "==" || operator === "===") && + leftOutput.value === rightOutput.value) || + ((operator === "!=" || operator === "!==") && + leftOutput.value !== rightOutput.value) || + (leftOutput.value !== null && + rightOutput.value !== null && + ((operator === ">" && leftOutput.value > rightOutput.value) || + (operator === "<" && leftOutput.value < rightOutput.value) || + (operator === "<=" && leftOutput.value <= rightOutput.value) || + (operator === ">=" && leftOutput.value >= rightOutput.value))) + ? ASL.trueCondition() + : ASL.falseCondition(); + } + + const [left, right] = + ASLGraph.isJsonPath(leftOutput) || ASLGraph.isConditionOutput(leftOutput) + ? [leftOutput, rightOutput] + : [ + rightOutput as ASLGraph.JsonPath | ASLGraph.ConditionOutput, + leftOutput, + ]; + // if the right is a variable and the left isn't, invert the operator + // 1 >= a -> a <= 1 + // a >= b -> a >= b + // a >= 1 -> a >= 1 + const op = leftOutput === left ? operator : invertBinaryOperator(operator); + + return ASLGraph.compare(left, right, op as any); + } + + export function compare( + left: ASLGraph.JsonPath | ASLGraph.ConditionOutput, + right: ASLGraph.Output, + operator: ASL.ValueComparisonOperators | "!=" | "!==" + ): Condition { + if ( + operator === "==" || + operator === "===" || + operator === ">" || + operator === "<" || + operator === ">=" || + operator === "<=" + ) { + if (ASLGraph.isConditionOutput(left)) { + return ( + ASLGraph.booleanCompare(left, right, operator) ?? ASL.falseCondition() + ); + } + const condition = ASL.or( + ASLGraph.nullCompare(left, right, operator), + ASLGraph.stringCompare(left, right, operator), + ASLGraph.booleanCompare(left, right, operator), + ASLGraph.numberCompare(left, right, operator) + ); + + if (ASLGraph.isJsonPath(right)) { + ASL.or( + // a === b while a and b are both not defined + ASL.not( + ASL.and(ASL.isPresent(left.jsonPath), ASL.isPresent(right.jsonPath)) + ), + // a !== undefined && b !== undefined + ASL.and( + ASL.isPresent(left.jsonPath), + ASL.isPresent(right.jsonPath), + // && a [op] b + condition + ) + ); + } + return ASL.and(ASL.isPresent(left.jsonPath), condition); + } else if (operator === "!=" || operator === "!==") { + return ASL.not(ASLGraph.compare(left, right, "==")); + } + + assertNever(operator); + } + + // Assumes the variable(s) are present and not null + export function stringCompare( + left: ASLGraph.JsonPath, + right: ASLGraph.Output, + operator: ASL.ValueComparisonOperators + ) { + if ( + ASLGraph.isJsonPath(right) || + (ASLGraph.isLiteralValue(right) && typeof right.value === "string") + ) { + return ASL.and( + ASL.isString(left.jsonPath), + ASLGraph.isJsonPath(right) + ? ASL.comparePathOfType( + left.jsonPath, + operator, + right.jsonPath, + "string" + ) + : ASL.compareValueOfType( + left.jsonPath, + operator, + right.value as string + ) + ); + } + return undefined; + } + + export function numberCompare( + left: ASLGraph.JsonPath, + right: ASLGraph.Output, + operator: ASL.ValueComparisonOperators + ) { + if ( + ASLGraph.isJsonPath(right) || + (ASLGraph.isLiteralValue(right) && typeof right.value === "number") + ) { + return ASL.and( + ASL.isNumeric(left.jsonPath), + ASLGraph.isJsonPath(right) + ? ASL.comparePathOfType( + left.jsonPath, + operator, + right.jsonPath, + "number" + ) + : ASL.compareValueOfType( + left.jsonPath, + operator, + right.value as number + ) + ); + } + return undefined; + } + + export function booleanCompare( + left: ASLGraph.JsonPath | ASLGraph.ConditionOutput, + right: ASLGraph.Output, + operator: ASL.ValueComparisonOperators + ) { + // (z == b) === (a ==c c) + if (ASLGraph.isConditionOutput(left)) { + if (operator === "===" || operator === "==") { + if (ASLGraph.isConditionOutput(right)) { + // (!left && !right) || (left && right) + return ASL.or( + ASL.and(ASL.not(left.condition), ASL.not(right.condition)), + ASL.and(left.condition, right.condition) + ); + } else if ( + ASLGraph.isLiteralValue(right) && + typeof right.value === "boolean" + ) { + // (a === b) === true + return right.value ? left.condition : ASL.not(left.condition); + } else if (ASLGraph.isJsonPath(right)) { + // (a === b) === c + return ASL.or( + ASL.and( + ASL.not(left.condition), + ASL.booleanEquals(right.jsonPath, false) + ), + ASL.and(left.condition, ASL.booleanEquals(right.jsonPath, true)) + ); + } + } + return undefined; + } + if (ASLGraph.isConditionOutput(right)) { + // a === (b === c) + return ASL.or( + ASL.and( + ASL.not(right.condition), + ASL.booleanEquals(left.jsonPath, false) + ), + ASL.and(right.condition, ASL.booleanEquals(left.jsonPath, true)) + ); + } + if (ASLGraph.isJsonPath(right) || typeof right.value === "boolean") { + return ASL.and( + ASL.isBoolean(left.jsonPath), + ASLGraph.isJsonPath(right) + ? ASL.comparePathOfType( + left.jsonPath, + operator, + right.jsonPath, + "boolean" + ) + : ASL.compareValueOfType( + left.jsonPath, + operator, + right.value as boolean + ) + ); + } + return undefined; + } + + export function nullCompare( + left: ASLGraph.JsonPath, + right: ASLGraph.Output, + operator: ASL.ValueComparisonOperators + ) { + if (operator === "==" || operator === "===") { + if (ASLGraph.isJsonPath(right)) { + return ASL.and(ASL.isNull(left.jsonPath), ASL.isNull(right.jsonPath)); + } else if (ASLGraph.isLiteralValue(right) && right.value === null) { + return ASL.isNull(left.jsonPath); + } + } + return undefined; + } + + /** + * Returns a object with the key formatted based on the contents of the value. + * in ASL, object keys that reference json path values must have a suffix of ".$" + * { "input.$": "$.value" } + */ + export function jsonAssignment( + key: string, + output: Exclude + ): Record { + return { + [ASLGraph.isJsonPath(output) ? `${key}.$` : key]: ASLGraph.isLiteralValue( + output + ) + ? output.value + : output.jsonPath, + }; + } + + export function isTruthyOutput(v: ASLGraph.Output): Condition { + return ASLGraph.isLiteralValue(v) + ? v.value + ? ASL.trueCondition() + : ASL.falseCondition() + : ASLGraph.isJsonPath(v) + ? ASL.isTruthy(v.jsonPath) + : v.condition; + } + + export function elementIn( + element: string | number, + targetJsonPath: ASLGraph.JsonPath + ): Condition { + const accessed = ASLGraph.accessConstant(targetJsonPath, element, true); + + if (ASLGraph.isLiteralValue(accessed)) { + return accessed.value === undefined + ? ASL.falseCondition() + : ASL.trueCondition(); + } else { + return ASL.isPresent(accessed.jsonPath); + } + } + + /** + * @param element - when true (or field is a number) the output json path will prefer to use the square bracket format. + * `$.obj[field]`. When false will prefer the dot format `$.obj.field`. + */ + export function accessConstant( + value: ASLGraph.Output, + field: string | number, + element: boolean + ): ASLGraph.JsonPath | ASLGraph.LiteralValue { + if (ASLGraph.isJsonPath(value)) { + return typeof field === "number" + ? { jsonPath: `${value.jsonPath}[${field}]` } + : element + ? { jsonPath: `${value.jsonPath}['${field}']` } + : { jsonPath: `${value.jsonPath}.${field}` }; + } + + if (ASLGraph.isLiteralValue(value) && value.value) { + const accessedValue = (() => { + if (Array.isArray(value.value)) { + if (typeof field === "number") { + return value.value[field]; + } + throw new SynthError( + ErrorCodes.StepFunctions_Invalid_collection_access, + "Accessor to an array must be a constant number" + ); + } else if (typeof value.value === "object") { + return value.value[field]; + } + throw new SynthError( + ErrorCodes.StepFunctions_Invalid_collection_access, + "Only a constant object or array may be accessed." + ); + })(); + + return typeof accessedValue === "string" && + (accessedValue.startsWith("$") || accessedValue.startsWith("States.")) + ? { jsonPath: accessedValue } + : { + value: accessedValue, + containsJsonPath: value.containsJsonPath, + }; + } + + throw new SynthError( + ErrorCodes.StepFunctions_Invalid_collection_access, + "Only a constant object or array may be accessed." + ); + } +} + +// to prevent the closure serializer from trying to import all of functionless. +export const deploymentOnlyModule = true; diff --git a/src/asl/index.ts b/src/asl/index.ts new file mode 100644 index 00000000..4805e2f0 --- /dev/null +++ b/src/asl/index.ts @@ -0,0 +1,3 @@ +export { ASL } from "./synth"; +export { ASLGraph } from "./asl-graph"; +export * from "./states"; diff --git a/src/asl/states.ts b/src/asl/states.ts new file mode 100644 index 00000000..198f91f2 --- /dev/null +++ b/src/asl/states.ts @@ -0,0 +1,295 @@ +export interface StateMachine { + Version?: "1.0"; + Comment?: string; + TimeoutSeconds?: number; + StartAt: keyof S; + States: S; +} + +export interface States { + [stateName: string]: State; +} + +export function isState(state: any): state is State { + return state && "Type" in state; +} + +export type State = + | Choice + | Fail + | MapTask + | ParallelTask + | Pass + | Succeed + | Task + | Wait; + +export type TerminalState = Succeed | Fail | Extract; + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-common-fields.html + */ +export type CommonFields = { + /** + * Holds a human-readable description of the state. + */ + Comment?: string; + /** + * A path that selects a portion of the state's input to be passed to the state's task for processing. If omitted, it has the value $ which designates the entire input. For more information, see Input and Output Processing). + */ + InputPath?: string; + /** + * A path that selects a portion of the state's input to be passed to the state's output. If omitted, it has the value $ which designates the entire input. For more information, see Input and Output Processing. + */ + OutputPath?: string; +} & ( + | { + /** + * The name of the next state that is run when the current state finishes. Some state types, such as Choice, allow multiple transition states. + */ + Next: string; + End?: never; + } + | { + /** + * Designates this state as a terminal state (ends the execution) if set to true. There can be any number of terminal states per state machine. Only one of Next or End can be used in a state. Some state types, such as Choice, don't support or use the End field. + */ + End: true; + Next?: never; + } +); + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-succeed-state.html + */ +export interface Succeed extends Omit { + Type: "Succeed"; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html + */ +export interface Fail extends Pick { + Type: "Fail"; + Error?: string; + Cause?: string; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-wait-state.html + */ +export type Wait = CommonFields & { + Type: "Wait"; + /** + * A time, in seconds, to wait before beginning the state specified in the Next field. + */ + Seconds?: number; + /** + * An absolute time to wait until beginning the state specified in the Next field. + * + * Timestamps must conform to the RFC3339 profile of ISO 8601, with the further restrictions that an uppercase T must separate the date and time portions, and an uppercase Z must denote that a numeric time zone offset is not present, for example, 2016-08-18T17:33:00Z. + */ + Timestamp?: string; + /** + * A time, in seconds, to wait before beginning the state specified in the Next field, specified using a path from the state's input data. + */ + SecondsPath?: string; + /** + * An absolute time to wait until beginning the state specified in the Next field, specified using a path from the state's input data. + */ + TimestampPath?: string; +}; + +export type Parameters = + | null + | boolean + | number + | string + | Parameters[] + | { + [name: string]: Parameters; + }; + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-pass-state.html + */ +export type Pass = CommonFields & { + Comment?: string; + Type: "Pass"; + Result?: any; + ResultPath?: string | null; + Parameters?: Parameters; +}; + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-task-state.html + */ +export type CommonTaskFields = CommonFields & { + Comment?: string; + Parameters?: Parameters; + ResultSelector?: Parameters; + ResultPath?: string | null; + Retry?: Retry[]; + Catch?: Catch[]; +}; + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-task-state.html + */ +export type Task = CommonTaskFields & { + Type: "Task"; + Resource: string; +}; + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html + */ +export interface Choice extends Omit { + Type: "Choice"; + Choices: Branch[]; + Default?: string; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html + */ +export interface Branch extends Condition { + Next: string; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html + */ +export interface Condition { + Variable?: string; + Not?: Condition; + And?: Condition[]; + Or?: Condition[]; + BooleanEquals?: boolean; + BooleanEqualsPath?: string; + IsBoolean?: boolean; + IsNull?: boolean; + IsNumeric?: boolean; + IsPresent?: boolean; + IsString?: boolean; + IsTimestamp?: boolean; + NumericEquals?: number; + NumericEqualsPath?: string; + NumericGreaterThan?: number; + NumericGreaterThanPath?: string; + NumericGreaterThanEquals?: number; + NumericGreaterThanEqualsPath?: string; + NumericLessThan?: number; + NumericLessThanPath?: string; + NumericLessThanEquals?: number; + NumericLessThanEqualsPath?: string; + StringEquals?: string; + StringEqualsPath?: string; + StringGreaterThan?: string; + StringGreaterThanPath?: string; + StringGreaterThanEquals?: string; + StringGreaterThanEqualsPath?: string; + StringLessThan?: string; + StringLessThanPath?: string; + StringLessThanEquals?: string; + StringLessThanEqualsPath?: string; + StringMatches?: string; + TimestampEquals?: string; + TimestampEqualsPath?: string; + TimestampGreaterThan?: string; + TimestampGreaterThanPath?: string; + TimestampGreaterThanEquals?: string; + TimestampGreaterThanEqualsPath?: string; + TimestampLessThan?: string; + TimestampLessThanPath?: string; + TimestampLessThanEquals?: string; + TimestampLessThanEqualsPath?: string; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html + */ +export interface Retry { + ErrorEquals: string[]; + IntervalSeconds?: number; + MaxAttempts?: number; + BackoffRate?: number; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html + */ +export interface Catch { + ErrorEquals: string[]; + ResultPath?: string | null; + Next: string; +} + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-map-state.html + */ +export type MapTask = CommonTaskFields & { + Type: "Map"; + /** + * The Iterator field’s value is an object that defines a state machine which will process each element of the array. + */ + Iterator: StateMachine; + /** + * The ItemsPath field’s value is a reference path identifying where in the effective input the array field is found. For more information, see ItemsPath. + * + * States within an Iterator field can only transition to each other, and no state outside the Iterator field can transition to a state within it. + * + * If any iteration fails, entire Map state fails, and all iterations are terminated. + */ + ItemsPath?: string; + /** + * Specifies where (in the input) to place the output of the branches. The input is then filtered as specified by the OutputPath field (if present) before being used as the state's output. For more information, see Input and Output Processing. + */ + ResultPath?: string | null; + /** + * The `MaxConcurrency` field’s value is an integer that provides an upper bound on how many invocations of the Iterator may run in parallel. For instance, a MaxConcurrency value of 10 will limit your Map state to 10 concurrent iterations running at one time. + * + * Concurrent iterations may be limited. When this occurs, some iterations will not begin until previous iterations have completed. The likelihood of this occurring increases when your input array has more than 40 items. + * + * The default value is 0, which places no quota on parallelism and iterations are invoked as concurrently as possible. + * + * A MaxConcurrency value of 1 invokes the Iterator once for each array element in the order of their appearance in the input, and will not start a new iteration until the previous has completed. + */ + MaxConcurrency?: number; +}; + +/** + * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-parallel-state.html + */ +export type ParallelTask = CommonTaskFields & { + Type: "Parallel"; + Branches: StateMachine[]; +}; + +export function isParallelTaskState(state: State): state is ParallelTask { + return state.Type === "Parallel"; +} +export function isMapTaskState(state: State): state is MapTask { + return state.Type === "Map"; +} +export function isTaskState(state: State): state is Task { + return state.Type === "Task"; +} +export function isPassState(state: State): state is Pass { + return state.Type === "Pass"; +} +export function isChoiceState(state: State): state is Choice { + return state.Type === "Choice"; +} +export function isFailState(state: State): state is Fail { + return state.Type === "Fail"; +} +export function isSucceedState(state: State): state is Succeed { + return state.Type === "Succeed"; +} +export function isWaitState(state: State): state is Wait { + return state.Type === "Wait"; +} + +// to prevent the closure serializer from trying to import all of functionless. +export const deploymentOnlyModule = true; diff --git a/src/asl.ts b/src/asl/synth.ts similarity index 77% rename from src/asl.ts rename to src/asl/synth.ts index 849782f3..862599c7 100644 --- a/src/asl.ts +++ b/src/asl/synth.ts @@ -1,536 +1,260 @@ import { aws_iam } from "aws-cdk-lib"; import { Construct } from "constructs"; -import { assertNever } from "./assert"; +import { assertNever } from "../assert"; import { + VariableDecl, + ParameterDecl, BindingElem, + FunctionLike, BindingName, BindingPattern, - FunctionLike, - ParameterDecl, - VariableDecl, -} from "./declaration"; -import { ErrorCodes, SynthError } from "./error-code"; +} from "../declaration"; +import { SynthError, ErrorCodes } from "../error-code"; import { - ArrowFunctionExpr, - CallExpr, - ElementAccessExpr, Expr, - FunctionExpr, - Identifier, NullLiteralExpr, + CallExpr, PropAccessExpr, + FunctionExpr, + ArrowFunctionExpr, + ElementAccessExpr, ReferenceExpr, -} from "./expression"; + Identifier, +} from "../expression"; import { - isArgument, - isArrayBinding, - isArrayLiteralExpr, - isAwaitExpr, - isBigIntExpr, - isBinaryExpr, - isBindingElem, - isBindingPattern, isBlockStmt, - isBooleanLiteralExpr, + isForOfStmt, + isParameterDecl, + isBindingPattern, + isBindingElem, + isVariableDecl, isBreakStmt, - isCallExpr, - isCaseClause, - isCatchClause, - isClassDecl, - isClassExpr, - isClassStaticBlockDecl, - isComputedPropertyNameExpr, - isConditionExpr, - isConstructorDecl, isContinueStmt, - isDebuggerStmt, - isDefaultClause, - isDeleteExpr, - isDoStmt, - isElementAccessExpr, - isEmptyStmt, - isErr, - isExprStmt, isForInStmt, - isForOfStmt, isForStmt, - isFunctionLike, - isGetAccessorDecl, - isIdentifier, + isWhileStmt, + isDoStmt, + isExprStmt, + isVariableDeclList, isIfStmt, - isImportKeyword, - isLabelledStmt, - isLiteralExpr, - isMethodDecl, + isReturnStmt, + isVariableStmt, + isThrowStmt, isNewExpr, + isCallExpr, + isReferenceExpr, + isPropAccessExpr, + isUndefinedLiteralExpr, + isTryStmt, + isDebuggerStmt, + isEmptyStmt, + isLabelledStmt, + isWithStmt, + isSwitchStmt, isNode, - isNoSubstitutionTemplateLiteral, - isNullLiteralExpr, - isNumberLiteralExpr, - isObjectBinding, + isTemplateExpr, + isVariableReference, + isElementAccessExpr, isObjectLiteralExpr, + isPropAssignExpr, + isComputedPropertyNameExpr, + isStringLiteralExpr, + isArrayLiteralExpr, isOmittedExpr, - isParameterDecl, - isParenthesizedExpr, + isLiteralExpr, + isUnaryExpr, isPostfixUnaryExpr, - isPrivateIdentifier, - isPropAccessExpr, - isPropAssignExpr, - isPropDecl, - isReferenceExpr, - isRegexExpr, - isReturnStmt, + isBinaryExpr, + isAwaitExpr, + isTypeOfExpr, + isConditionExpr, + isParenthesizedExpr, + isStmt, + isArgument, + isArrayBinding, + isObjectBinding, + isErr, + isBigIntExpr, + isBooleanLiteralExpr, + isSuperKeyword, + isImportKeyword, + isNullLiteralExpr, + isNumberLiteralExpr, isSetAccessorDecl, + isGetAccessorDecl, + isMethodDecl, isSpreadAssignExpr, isSpreadElementExpr, - isStmt, - isStringLiteralExpr, - isSuperKeyword, - isSwitchStmt, + isThisExpr, + isClassExpr, + isYieldExpr, + isRegexExpr, + isDeleteExpr, + isVoidExpr, isTaggedTemplateExpr, - isTemplateExpr, + isClassDecl, + isClassStaticBlockDecl, + isConstructorDecl, + isPropDecl, + isFunctionLike, + isIdentifier, + isCatchClause, + isDefaultClause, + isCaseClause, isTemplateHead, - isTemplateMiddle, - isTemplateSpan, isTemplateTail, - isThisExpr, - isThrowStmt, - isTryStmt, - isTypeOfExpr, - isUnaryExpr, - isUndefinedLiteralExpr, - isVariableDecl, - isVariableDeclList, - isVariableReference, - isVariableStmt, - isVoidExpr, - isWhileStmt, - isWithStmt, - isYieldExpr, -} from "./guards"; + isTemplateSpan, + isTemplateMiddle, + isNoSubstitutionTemplateLiteral, + isPrivateIdentifier, +} from "../guards"; import { - IntegrationImpl, - isIntegration, isIntegrationCallPattern, tryFindIntegration, -} from "./integration"; -import { BindingDecl, FunctionlessNode } from "./node"; -import { emptySpan } from "./span"; + IntegrationImpl, + isIntegration, +} from "../integration"; +import { FunctionlessNode, BindingDecl } from "../node"; +import { emptySpan } from "../span"; import { BlockStmt, - BreakStmt, + Stmt, + ReturnStmt, ContinueStmt, - ForInStmt, + BreakStmt, IfStmt, - ReturnStmt, - Stmt, -} from "./statement"; -import { StepFunctionError } from "./step-function"; + ForInStmt, +} from "../statement"; +import { StepFunctionError } from "../step-function"; import { - anyOf, + UniqueNameGenerator, DeterministicNameGenerator, + anyOf, evalToConstant, - invertBinaryOperator, isPromiseAll, - UniqueNameGenerator, -} from "./util"; -import { visitEachChild } from "./visit"; - -export interface StateMachine { - Version?: "1.0"; - Comment?: string; - TimeoutSeconds?: number; - StartAt: keyof S; - States: S; -} +} from "../util"; +import { visitEachChild } from "../visit"; +import { ASLGraph } from "./asl-graph"; +import { + States, + CommonFields, + isTaskState, + isMapTaskState, + isParallelTaskState, + MapTask, + StateMachine, + Pass, + Choice, + Fail, + Succeed, + Wait, + Parameters, + Condition, + State, +} from "./states"; -export interface States { - [stateName: string]: State; -} +/** + * Handler used by {@link ASL.evalExpr}* functions. + * + * @param output - the {@link ASLGraph.Output} generated by the output by the expression. + * @param context - some helper functions specific to the evaluation context. + * @returns a state with output or output to be merged into the other states generated during evaluation. + */ +type EvalExprHandler = ( + output: Output, + context: EvalExprContext +) => ASLGraph.NodeResults; -export function isState(state: any): state is State { - return state && "Type" in state; +export interface EvalExprContext { + /** + * Callback provided to inject additional states into the graph. + * The state will be joined (@see ASLGraph.joinSubStates ) with the previous and next states in the order received. + */ + addState: (state: ASLGraph.NodeState | ASLGraph.SubState) => void; } -export type State = - | Choice - | Fail - | MapTask - | ParallelTask - | Pass - | Succeed - | Task - | Wait; - -export type TerminalState = Succeed | Fail | Extract; - /** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-common-fields.html + * Handler used by {@link ASL.evalContext} functions. + * + * @param context - some helper functions specific to the evaluation context. + * @returns a state with output or output to be merged into the other states generated during evaluation. */ -export type CommonFields = { +type EvalContextHandler = (context: EvalContextContext) => ASLGraph.NodeResults; + +export interface EvalContextContext { /** - * Holds a human-readable description of the state. + * Callback provided to inject additional states into the graph. + * The state will be joined (@see ASLGraph.joinSubStates ) with the previous and next states in the order received. */ - Comment?: string; + addState: (state: ASLGraph.NodeState | ASLGraph.SubState) => void; /** - * A path that selects a portion of the state's input to be passed to the state's task for processing. If omitted, it has the value $ which designates the entire input. For more information, see Input and Output Processing). + * Evaluates a single expression and returns the {@link ASLGraph.Output}. + * + * Any generated states will be merged in with the output. + * + * This method is the same as {@link ASL.evalExpr}, but it adds any generated states to the current {@link ASL.evalContext}. */ - InputPath?: string; + evalExpr: (expr: Expr, allowUndefined?: boolean) => ASLGraph.Output; /** - * A path that selects a portion of the state's input to be passed to the state's output. If omitted, it has the value $ which designates the entire input. For more information, see Input and Output Processing. + * Evaluates a single expression and returns the {@link ASLGraph.Output}. + * + * Any generated states will be merged in with the output. + * + * * If the output was a {@link ASLGraph.LiteralValue}, a new state will be added that turns the literal into a {@link ASL.JsonPath}. + * * If the output was a {@link ASLGraph.ConditionOutput}, a new {@link Choice} state will turn the conditional into a boolean + * + * This method is the same as {@link ASL.evalExprToJsonPath}, but it adds any generated states to the current {@link ASL.evalContext}. */ - OutputPath?: string; -} & ( - | { - /** - * The name of the next state that is run when the current state finishes. Some state types, such as Choice, allow multiple transition states. - */ - Next: string; - End?: never; - } - | { - /** - * Designates this state as a terminal state (ends the execution) if set to true. There can be any number of terminal states per state machine. Only one of Next or End can be used in a state. Some state types, such as Choice, don't support or use the End field. - */ - End: true; - Next?: never; - } -); + evalExprToJsonPath: ( + expr: Expr, + allowUndefined?: boolean + ) => ASLGraph.JsonPath; + /** + * Evaluates a single expression and returns the {@link ASLGraph.JsonPath} or {@link ASLGraph.LiteralValue}. + * + * Any generated states will be merged in with the output. + * + * If the output was a {@link ASLGraph.ConditionOutput}, a new {@link Choice} state will turn the conditional into a boolean + * and return a {@link ASLGraph.JsonPath}. + * + * This method is the same as {@link ASL.evalExprToJsonPathOrLiteral}, but it adds any generated states to the current {@link ASL.evalContext}. + */ + evalExprToJsonPathOrLiteral: ( + expr: Expr, + allowUndefined?: boolean + ) => ASLGraph.JsonPath | ASLGraph.LiteralValue; +} /** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-succeed-state.html + * The name of the functionless context data node used in {@link FUNCTIONLESS_CONTEXT_JSON_PATH}. */ -export interface Succeed extends Omit { - Type: "Succeed"; -} - +const FUNCTIONLESS_CONTEXT_NAME = "fnl_context"; /** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html + * A json path which stores functionless context data like the input and a hard to manufacture null value + * + * This path/variable must start with a letter. + * https://twitter.com/sussmansa/status/1542777348616990720?s=20&t=2PepSKvzPhojs_x01WoQVQ */ -export interface Fail extends Pick { - Type: "Fail"; - Error?: string; - Cause?: string; -} +const FUNCTIONLESS_CONTEXT_JSON_PATH = `$.${FUNCTIONLESS_CONTEXT_NAME}`; /** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-wait-state.html + * Amazon States Language (ASL) Generator. */ -export type Wait = CommonFields & { - Type: "Wait"; +export class ASL { /** - * A time, in seconds, to wait before beginning the state specified in the Next field. + * A friendly name to identify the Functionless Context. */ - Seconds?: number; + static readonly ContextName = "Amazon States Language"; /** - * An absolute time to wait until beginning the state specified in the Next field. - * - * Timestamps must conform to the RFC3339 profile of ISO 8601, with the further restrictions that an uppercase T must separate the date and time portions, and an uppercase Z must denote that a numeric time zone offset is not present, for example, 2016-08-18T17:33:00Z. + * Tag this instance with its Functionless Context ({@link this.ContextName}) */ - Timestamp?: string; + readonly kind = ASL.ContextName; /** - * A time, in seconds, to wait before beginning the state specified in the Next field, specified using a path from the state's input data. + * The Amazon States Language (ASL) State Machine Definition synthesized fro the {@link decl}. */ - SecondsPath?: string; + readonly definition: StateMachine; /** - * An absolute time to wait until beginning the state specified in the Next field, specified using a path from the state's input data. - */ - TimestampPath?: string; -}; - -export type Parameters = - | null - | boolean - | number - | string - | Parameters[] - | { - [name: string]: Parameters; - }; - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-pass-state.html - */ -export type Pass = CommonFields & { - Comment?: string; - Type: "Pass"; - Result?: any; - ResultPath?: string | null; - Parameters?: Parameters; -}; - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-task-state.html - */ -export type CommonTaskFields = CommonFields & { - Comment?: string; - Parameters?: Parameters; - ResultSelector?: Parameters; - ResultPath?: string | null; - Retry?: Retry[]; - Catch?: Catch[]; -}; - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-task-state.html - */ -export type Task = CommonTaskFields & { - Type: "Task"; - Resource: string; -}; - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html - */ -export interface Choice extends Omit { - Type: "Choice"; - Choices: Branch[]; - Default?: string; -} - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html - */ -export interface Branch extends Condition { - Next: string; -} - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html - */ -export interface Condition { - Variable?: string; - Not?: Condition; - And?: Condition[]; - Or?: Condition[]; - BooleanEquals?: boolean; - BooleanEqualsPath?: string; - IsBoolean?: boolean; - IsNull?: boolean; - IsNumeric?: boolean; - IsPresent?: boolean; - IsString?: boolean; - IsTimestamp?: boolean; - NumericEquals?: number; - NumericEqualsPath?: string; - NumericGreaterThan?: number; - NumericGreaterThanPath?: string; - NumericGreaterThanEquals?: number; - NumericGreaterThanEqualsPath?: string; - NumericLessThan?: number; - NumericLessThanPath?: string; - NumericLessThanEquals?: number; - NumericLessThanEqualsPath?: string; - StringEquals?: string; - StringEqualsPath?: string; - StringGreaterThan?: string; - StringGreaterThanPath?: string; - StringGreaterThanEquals?: string; - StringGreaterThanEqualsPath?: string; - StringLessThan?: string; - StringLessThanPath?: string; - StringLessThanEquals?: string; - StringLessThanEqualsPath?: string; - StringMatches?: string; - TimestampEquals?: string; - TimestampEqualsPath?: string; - TimestampGreaterThan?: string; - TimestampGreaterThanPath?: string; - TimestampGreaterThanEquals?: string; - TimestampGreaterThanEqualsPath?: string; - TimestampLessThan?: string; - TimestampLessThanPath?: string; - TimestampLessThanEquals?: string; - TimestampLessThanEqualsPath?: string; -} - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html - */ -export interface Retry { - ErrorEquals: string[]; - IntervalSeconds?: number; - MaxAttempts?: number; - BackoffRate?: number; -} - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html - */ -export interface Catch { - ErrorEquals: string[]; - ResultPath?: string | null; - Next: string; -} - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-map-state.html - */ -export type MapTask = CommonTaskFields & { - Type: "Map"; - /** - * The Iterator field’s value is an object that defines a state machine which will process each element of the array. - */ - Iterator: StateMachine; - /** - * The ItemsPath field’s value is a reference path identifying where in the effective input the array field is found. For more information, see ItemsPath. - * - * States within an Iterator field can only transition to each other, and no state outside the Iterator field can transition to a state within it. - * - * If any iteration fails, entire Map state fails, and all iterations are terminated. - */ - ItemsPath?: string; - /** - * Specifies where (in the input) to place the output of the branches. The input is then filtered as specified by the OutputPath field (if present) before being used as the state's output. For more information, see Input and Output Processing. - */ - ResultPath?: string | null; - /** - * The `MaxConcurrency` field’s value is an integer that provides an upper bound on how many invocations of the Iterator may run in parallel. For instance, a MaxConcurrency value of 10 will limit your Map state to 10 concurrent iterations running at one time. - * - * Concurrent iterations may be limited. When this occurs, some iterations will not begin until previous iterations have completed. The likelihood of this occurring increases when your input array has more than 40 items. - * - * The default value is 0, which places no quota on parallelism and iterations are invoked as concurrently as possible. - * - * A MaxConcurrency value of 1 invokes the Iterator once for each array element in the order of their appearance in the input, and will not start a new iteration until the previous has completed. - */ - MaxConcurrency?: number; -}; - -/** - * @see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-parallel-state.html - */ -export type ParallelTask = CommonTaskFields & { - Type: "Parallel"; - Branches: StateMachine[]; -}; - -export function isParallelTaskState(state: State): state is ParallelTask { - return state.Type === "Parallel"; -} -export function isMapTaskState(state: State): state is MapTask { - return state.Type === "Map"; -} -export function isTaskState(state: State): state is Task { - return state.Type === "Task"; -} -export function isPassState(state: State): state is Pass { - return state.Type === "Pass"; -} -export function isChoiceState(state: State): state is Choice { - return state.Type === "Choice"; -} -export function isFailState(state: State): state is Fail { - return state.Type === "Fail"; -} -export function isSucceedState(state: State): state is Succeed { - return state.Type === "Succeed"; -} -export function isWaitState(state: State): state is Wait { - return state.Type === "Wait"; -} - -/** - * Handler used by {@link ASL.evalExpr}* functions. - * - * @param output - the {@link ASLGraph.Output} generated by the output by the expression. - * @param context - some helper functions specific to the evaluation context. - * @returns a state with output or output to be merged into the other states generated during evaluation. - */ -type EvalExprHandler = ( - output: Output, - context: EvalExprContext -) => ASLGraph.NodeResults; - -export interface EvalExprContext { - /** - * Callback provided to inject additional states into the graph. - * The state will be joined (@see ASLGraph.joinSubStates ) with the previous and next states in the order received. - */ - addState: (state: ASLGraph.NodeState | ASLGraph.SubState) => void; -} - -/** - * Handler used by {@link ASL.evalContext} functions. - * - * @param context - some helper functions specific to the evaluation context. - * @returns a state with output or output to be merged into the other states generated during evaluation. - */ -type EvalContextHandler = (context: EvalContextContext) => ASLGraph.NodeResults; - -export interface EvalContextContext { - /** - * Callback provided to inject additional states into the graph. - * The state will be joined (@see ASLGraph.joinSubStates ) with the previous and next states in the order received. - */ - addState: (state: ASLGraph.NodeState | ASLGraph.SubState) => void; - /** - * Evaluates a single expression and returns the {@link ASLGraph.Output}. - * - * Any generated states will be merged in with the output. - * - * This method is the same as {@link ASL.evalExpr}, but it adds any generated states to the current {@link ASL.evalContext}. - */ - evalExpr: (expr: Expr, allowUndefined?: boolean) => ASLGraph.Output; - /** - * Evaluates a single expression and returns the {@link ASLGraph.Output}. - * - * Any generated states will be merged in with the output. - * - * * If the output was a {@link ASLGraph.LiteralValue}, a new state will be added that turns the literal into a {@link ASL.JsonPath}. - * * If the output was a {@link ASLGraph.ConditionOutput}, a new {@link Choice} state will turn the conditional into a boolean - * - * This method is the same as {@link ASL.evalExprToJsonPath}, but it adds any generated states to the current {@link ASL.evalContext}. - */ - evalExprToJsonPath: ( - expr: Expr, - allowUndefined?: boolean - ) => ASLGraph.JsonPath; - /** - * Evaluates a single expression and returns the {@link ASLGraph.JsonPath} or {@link ASLGraph.LiteralValue}. - * - * Any generated states will be merged in with the output. - * - * If the output was a {@link ASLGraph.ConditionOutput}, a new {@link Choice} state will turn the conditional into a boolean - * and return a {@link ASLGraph.JsonPath}. - * - * This method is the same as {@link ASL.evalExprToJsonPathOrLiteral}, but it adds any generated states to the current {@link ASL.evalContext}. - */ - evalExprToJsonPathOrLiteral: ( - expr: Expr, - allowUndefined?: boolean - ) => ASLGraph.JsonPath | ASLGraph.LiteralValue; -} - -/** - * The name of the functionless context data node used in {@link FUNCTIONLESS_CONTEXT_JSON_PATH}. - */ -const FUNCTIONLESS_CONTEXT_NAME = "fnl_context"; -/** - * A json path which stores functionless context data like the input and a hard to manufacture null value - * - * This path/variable must start with a letter. - * https://twitter.com/sussmansa/status/1542777348616990720?s=20&t=2PepSKvzPhojs_x01WoQVQ - */ -const FUNCTIONLESS_CONTEXT_JSON_PATH = `$.${FUNCTIONLESS_CONTEXT_NAME}`; - -/** - * Amazon States Language (ASL) Generator. - */ -export class ASL { - /** - * A friendly name to identify the Functionless Context. - */ - static readonly ContextName = "Amazon States Language"; - /** - * Tag this instance with its Functionless Context ({@link this.ContextName}) - */ - readonly kind = ASL.ContextName; - /** - * The Amazon States Language (ASL) State Machine Definition synthesized fro the {@link decl}. - */ - readonly definition: StateMachine; - /** - * The {@link FunctionLike} AST representation of the State Machine. + * The {@link FunctionLike} AST representation of the State Machine. */ readonly decl: FunctionLike; private readonly stateNamesGenerator = new UniqueNameGenerator( @@ -4354,1178 +4078,6 @@ function analyzeFlow(node: FunctionlessNode): FlowResult { ); } -/** - * ASL Graph is an intermediate API used to represent a nested and dynamic ASL State Machine Graph. - * - * Unlike ASL, the ASL graph supports nested nodes, can associate nodes to {@link FunctionlessNode}s, and contains a representation of the output of a state. - * - * ASL Graph is completely stateless. - */ -export namespace ASLGraph { - /** - * Used by integrations as a placeholder for the "Next" property of a task. - * - * When task.Next is ASLGraph.DeferNext, Functionless will replace the Next with the appropriate value. - * It may also add End or ResultPath based on the scenario. - */ - export const DeferNext: string = "__DeferNext"; - - export function isSubState( - state: ASLGraph.NodeState | ASLGraph.SubState | ASLGraph.NodeResults - ): state is ASLGraph.SubState { - return state && "startState" in state; - } - - /** - * A Sub-State is a collection of possible return values. - * A start state is the first state in the result. It will take on the name of the parent statement node. - * States are zero to many named states or sub-stages that will take on the name of the parent statement node. - */ - export interface SubState { - startState: string; - node?: FunctionlessNode; - states: { [stateName: string]: ASLGraph.NodeState | ASLGraph.SubState }; - } - - export const isStateOrSubState = anyOf(isState, ASLGraph.isSubState); - - /** - * An {@link ASLGraph} interface which adds an optional {@link FunctionlessNode} to a state. - * - * The node is used to name the state. - */ - export type NodeState = S & { - node?: FunctionlessNode; - }; - - /** - * The possible outputs of evaluating an {@link Expr}. - * - * State - A state with an {@link ASLGraph.Output} and optional {@link FunctionlessNode} - * SubStates - A sub-state graph with an {@link ASLGraph.Output} and optional {@link FunctionlessNode} - * JsonPath - a JSON Path Variable Reference, the consumer should use where json path is valid, ignore, or fail. - * Value - a Value of type number, string, boolean, object, or null. Consumers should use where values can be used or turn into JsonPath using a {@link Pass} state. - */ - export type NodeResults = - | ASLGraph.OutputState - | ASLGraph.OutputSubState - | ASLGraph.Output; - - /** - * A compound state is a state node that may contain a simple Constant or Variable output instead of - * built states or sub-states. - * - * Compound states are designed to be incorporated into existing states or turned into - * states before they are returned up. - * - * Compound states cannot be nested in sub-states. - */ - export interface OutputSubState extends ASLGraph.SubState { - output: ASLGraph.Output; - } - - /** - * An {@link ASLGraph} interface which adds an {@link ASLGraph.Output} a state. - * - * The node is used to name the state. - */ - export type OutputState = NodeState & { - output: ASLGraph.Output; - }; - - export function isOutputStateOrSubState( - state: any - ): state is ASLGraph.OutputSubState | ASLGraph.OutputState { - return "output" in state; - } - - /** - * A literal value of type string, number, boolean, object, or null. - * - * If this is an Object, the object may contain nested JsonPaths as denoted by `containsJsonPath`. - */ - export interface LiteralValue { - /** - * Whether there is json path in the constant. - * - * Helps determine where this constant can go for validation and - * when false use Result in a Pass State instead of Parameters - */ - containsJsonPath: boolean; - value: string | number | null | boolean | Record | any[]; - } - - /** - * A json path based state values reference. - */ - export interface JsonPath { - jsonPath: string; - } - - export interface ConditionOutput { - condition: Condition; - } - - export type Output = - | ASLGraph.LiteralValue - | ASLGraph.JsonPath - | ASLGraph.ConditionOutput; - - export function isLiteralValue(state: any): state is ASLGraph.LiteralValue { - return "value" in state; - } - - export function isJsonPath(state: any): state is ASLGraph.JsonPath { - return "jsonPath" in state; - } - - export function isConditionOutput( - state: any - ): state is ASLGraph.ConditionOutput { - return "condition" in state; - } - - export const isAslGraphOutput = anyOf( - isLiteralValue, - isJsonPath, - isConditionOutput - ); - - /** - * Wires together an array of {@link State} or {@link ASLGraph.SubState} nodes in the order given. - * Any state which is missing Next/End will be given a Next value of the next state with the final state - * either being left as is. - */ - export function joinSubStates( - node?: FunctionlessNode, - ...subStates: ( - | ASLGraph.NodeState - | ASLGraph.SubState - | ASLGraph.NodeResults - | undefined - )[] - ): ASLGraph.SubState | ASLGraph.NodeState | undefined { - if (subStates.length === 0) { - return undefined; - } - - const realStates = subStates - .filter((x) => !!x) - .filter(ASLGraph.isStateOrSubState); - return realStates.length === 0 - ? undefined - : realStates.length === 1 - ? { node, ...realStates[0]! } - : { - startState: "0", - node, - states: Object.fromEntries( - realStates.map((subState, i) => { - return [ - `${i}`, - i === realStates.length - 1 - ? subState - : updateDeferredNextStates({ Next: `${i + 1}` }, subState), - ]; - }) - ), - }; - } - - /** - * Used to lazily provide the next step to a provided state or nested set of states. - * - * Recursively traverse sub-states down to regular states, replacing any - * nodes with `Next: ASLGraph.DeferNext` or `Next: undefined` with the given props. - * - * Note: States without `Next` are ignored and {@link Map} states replace Default and `Choices[].Next` instead. - */ - export function updateDeferredNextStates( - props: - | { - End: true; - } - | { - Next: string; - }, - state: T - ): T { - return ASLGraph.isSubState(state) - ? updateDeferredNextSubStates>(props, state) - : (updateDeferredNextState(props, state) as T); - } - - /** - * Updates DeferNext states for an entire sub-state. - */ - function updateDeferredNextSubStates( - props: - | { - End: true; - } - | { - Next: string; - }, - subState: T - ): T { - // address the next state as a level up to keep the name unique. - const updatedProps = - "Next" in props && props.Next - ? { - ...props, - Next: `../${props.Next}`, - } - : props; - return { - ...subState, - states: Object.fromEntries( - Object.entries(subState.states ?? {}).map(([id, state]) => { - return [id, updateDeferredNextStates(updatedProps, state)]; - }) - ), - }; - } - - /** - * Step functions can fail to deploy when extraneous properties are left on state nodes. - * Only inject the properties the state type can handle. - * - * For example: https://github.com/functionless/functionless/issues/308 - * A Wait state with `ResultPath: null` was failing to deploy. - */ - function updateDeferredNextState( - props: - | { - End: true; - } - | { - Next: string; - }, - state: T - ): T { - const [End, Next] = - "End" in props ? [props.End, undefined] : [undefined, props.Next]; - - if (isChoiceState(state)) { - return { - ...state, - Choices: state.Choices.map((choice) => ({ - ...choice, - Next: choice.Next === ASLGraph.DeferNext ? Next! : choice.Next, - })), - Default: state.Default === ASLGraph.DeferNext ? Next : state.Default, - }; - } else if (isFailState(state) || isSucceedState(state)) { - return state; - } else if (isWaitState(state)) { - return { - ...state, - End: state.Next === ASLGraph.DeferNext ? End : state.End, - Next: state.Next === ASLGraph.DeferNext ? Next : state.Next, - } as T; - } else if ( - isTaskState(state) || - isParallelTaskState(state) || - isMapTaskState(state) - ) { - return { - ...state, - Catch: state.Catch - ? state.Catch.map((_catch) => ({ - ..._catch, - Next: _catch.Next === ASLGraph.DeferNext ? Next : _catch.Next, - })) - : undefined, - End: state.Next === ASLGraph.DeferNext ? End : state.End, - Next: state.Next === ASLGraph.DeferNext ? Next : state.Next, - } as T; - } else if (isPassState(state)) { - return { - ...state, - End: state.Next === ASLGraph.DeferNext ? End : state.End, - Next: state.Next === ASLGraph.DeferNext ? Next : state.Next, - }; - } - assertNever(state); - } - - /** - * Helper which can update a Asl state to a new output. - * Sometimes the output can be updated in places like when accessing a constant or variable. - * - * If the state is a compound state, only the output needs to change, not the states it contains. - * - * ```ts - * const obj = { a: { b: 1 } }; - * return obj.a.b; - * ``` - * - * output of obj.a - * { startState: ..., states: {...}, output: { jsonPath: "$.obj.a" } } - * - * output of obj.a.b - * { startState: ..., states: {...}, output: { jsonPath: "$.obj.a.b" } } - * - * Only the jsonPath has been mutated because no one used use intermediate output. - */ - export function updateAslStateOutput( - state: ASLGraph.NodeResults, - newOutput: ASLGraph.Output - ) { - if (ASLGraph.isOutputStateOrSubState(state)) { - return { - ...state, - output: newOutput, - }; - } - return newOutput; - } - - /** - * Key map for re-writing relative state names to absolute - */ - interface NameMap { - parent?: NameMap; - localNames: Record; - } - - /** - * Transforms an {@link ASLGraph.AslState} or {@link ASLGraph.SubState} into a ASL {@link States} collection of flat states. - * - * Uses the parent name as a starting point. All state nodes of sub-states will be given the name of their parent. - * - * Sub-States with local or relative state references will be rewritten to the updated parent state name. - * - * Removes unreachable states from the graph. Unreachable states will cause step functions to fail. - * - * sub state - * ```ts - * { - * startState: "default", - * states: { - * default: { Next: 'b' }, - * b: { Next: 'c' }, - * c: { Next: 'externalState' } - * } - * } - * ``` - * - * Parent state name: parentState - * - * rewrite - * ```ts - * { - * parentState: { Next: 'b__parentState' }, - * b__parentState: { Next: 'c__parentState' }, - * c__parentState: { Next: 'externalState' } - * } - * ``` - * - * Local State Names - * - * In the below example, default, b, and c are all local state names. - * - * ```ts - * { - * startState: "default", - * states: { - * default: { Next: 'b' }, - * b: { Next: 'c' }, - * c: { Next: 'externalState' } - * } - * } - * ``` - * - * Relative state names - * - * Path structures can be used to denote relative paths. ex: `../stateName`. - * - * ```ts - * { - * startState: "default", - * states: { - * default: { Next: 'b' }, - * b: { - * startState: "start", - * states: { - * start: { - * Next: "../c" - * } - * } - * }, - * c: { Next: 'externalState' } - * } - * } - * ``` - * - * In the above example, b/start's next state is c in it's parent state. - * - * Currently referencing child states (ex: `./b/start`) is not supported. - * - * All state names not found in local or parent sub-states will be assumed to be top level state names and will not be re-written. - */ - export function toStates( - startState: string, - states: - | ASLGraph.NodeState - | ASLGraph.SubState - | ASLGraph.OutputState - | ASLGraph.OutputSubState, - getStateNames: ( - parentName: string, - states: ASLGraph.SubState - ) => Record - ): States { - const namedStates = internal(startState, states, { localNames: {} }); - - /** - * Find any choice states that can be joined with their target state. - * TODO: generalize the optimization statements. - */ - const updatedStates = joinChainedChoices( - startState, - /** - * Remove any states with no effect (Pass, generally) - * The incoming states to the empty states are re-wired to the outgoing transition of the empty state. - */ - removeEmptyStates(startState, namedStates) - ); - - const reachableStates = findReachableStates(startState, updatedStates); - - // only take the reachable states - return Object.fromEntries( - Object.entries(updatedStates).filter(([name]) => - reachableStates.has(name) - ) - ); - - function internal( - parentName: string, - states: - | ASLGraph.NodeState - | ASLGraph.SubState - | ASLGraph.OutputState - | ASLGraph.OutputSubState, - stateNameMap: NameMap - ): [string, State][] { - if (!states) { - return []; - } else if (!ASLGraph.isSubState(states)) { - // strip output and node off of the state object. - const { node, output, ...updated } = ( - rewriteStateTransitions(states, stateNameMap) - ); - return [[parentName, updated]]; - } else { - const nameMap: NameMap = { - parent: stateNameMap, - localNames: getStateNames(parentName, states), - }; - return Object.entries(states.states).flatMap(([key, state]) => { - const parentName = nameMap.localNames[key]; - if (!parentName) { - throw new SynthError( - ErrorCodes.Unexpected_Error, - `Expected all local state names to be provided with a parent name, found ${key}` - ); - } - return internal(parentName, state, nameMap); - }); - } - } - } - - /** - * Given a directed adjacency matrix, return a `Set` of all reachable states from the start state. - */ - function findReachableStates( - startState: string, - states: Record - ) { - const visited = new Set(); - - // starting from the start state, find all reachable states - depthFirst(startState); - - return visited; - - function depthFirst(stateName: string) { - if (visited.has(stateName)) return; - visited.add(stateName); - const state = states[stateName]!; - visitTransition(state, depthFirst); - } - } - - function removeEmptyStates( - startState: string, - stateEntries: [string, State][] - ): [string, State][] { - /** - * Find all {@link Pass} states that do not do anything. - */ - const emptyStates = Object.fromEntries( - stateEntries.filter((entry): entry is [string, Pass] => { - const [name, state] = entry; - return ( - name !== startState && - isPassState(state) && - !!state.Next && - !( - state.End || - state.InputPath || - state.OutputPath || - state.Parameters || - state.Result || - state.ResultPath - ) - ); - }) - ); - - const emptyTransitions = computeEmptyStateToUpdatedTransition(emptyStates); - - // return the updated set of name to state. - return stateEntries.flatMap(([name, state]) => { - if (name in emptyTransitions) { - return []; - } - - return [ - [ - name, - visitTransition(state, (transition) => - transition in emptyTransitions - ? emptyTransitions[transition]! - : transition - ), - ], - ]; - }); - - /** - * Find the updated next value for all of the empty states. - * If the updated Next cannot be determined, do not remove the state. - */ - function computeEmptyStateToUpdatedTransition( - emptyStates: Record - ) { - return Object.fromEntries( - Object.entries(emptyStates).flatMap(([name, { Next }]) => { - const newNext = Next ? getNext(Next, []) : Next; - - /** - * If the updated Next value for this state cannot be determined, - * do not remove the state. - * - * This can because the state has no Next value (Functionless bug) - * or because all of the states in a cycle are empty. - */ - if (!newNext) { - return []; - } - - return [[name, newNext]]; - - /** - * When all states in a cycle are empty, the cycle will be impossible to exit. - * - * Note: This should be a rare case and is not an attempt to find any non-terminating logic. - * ex: `for(;;){}` - * Adding most conditions, incrementors, or bodies will not run into this issue. - * - * ```ts - * { - * 1: { Type: "???", Next: 2 }, - * 2: { Type: "Pass", Next: 3 }, - * 3: { Type: "Pass", Next: 4 }, - * 4: { Type: "Pass", Next: 2 } - * } - * ``` - * - * State 1 is any state that transitions to state 2. - * State 2 transitions to empty state 3 - * State 3 transitions to empty state 4 - * State 4 transitions back to empty state 2. - * - * Empty Pass states provide no value and will be removed. - * Empty Pass states can never fail and no factor can change where it goes. - * - * This is not an issue for other states which may fail or inject other logic to change the next state. - * Even the Wait stat could be used in an infinite loop if the machine is terminated from external source. - * - * If this happens, return undefined. - */ - function getNext( - transition: string, - seen: string[] = [] - ): string | undefined { - if (seen?.includes(transition)) { - return undefined; - } - return transition in emptyStates - ? getNext( - emptyStates[transition]!.Next!, - seen ? [...seen, transition] : [transition] - ) - : transition; - } - }) - ); - } - } - - /** - * A {@link Choice} state that points to another {@link Choice} state can adopt the target state's - * choices and Next without adding an additional transition. - * - * 1 - * if a -> 2 - * if b -> 3 - * else -> 4 - * 2 - * if c -> 3 - * else -> 4 - * 3 - Task - * 4 - * if e -> 5 - * else -> 6 - * 5 - Task - * 6 - Task - * - * => - * - * 1 - * if a && c -> 3 (1 and 2) - * if a && e -> 5 (1 and 4) - * if b -> 3 - * if e -> 5 (4) - * else -> 6 (4's else) - * 2 - remove (if nothing else points to it) - * 3 - Task - * 4 - remove (if nothing else points to it) - * 5 - Task - * 6 - Task - */ - function joinChainedChoices( - startState: string, - stateEntries: [string, State][] - ) { - const stateMap = Object.fromEntries(stateEntries); - - const updatedStates: Record = {}; - - depthFirst(startState); - - // we can assume that all null states have been updated by here. - return updatedStates as Record; - - function depthFirst(state: string): State | null { - if (state in updatedStates) return updatedStates[state]!; - const stateObj = stateMap[state]!; - if (!isChoiceState(stateObj)) { - updatedStates[state] = stateObj; - visitTransition(stateObj, (next) => { - depthFirst(next); - }); - // no change - return stateObj; - } - // set self to null to 1) halt circular references 2) avoid circular merges between choices. - // if #2 happens, that choice will always fail as state cannot change between transitions. - updatedStates[state] = null; - const branches = stateObj.Choices.flatMap((branch) => { - const { Next: branchNext, ...branchCondition } = branch; - const nextState = depthFirst(branchNext); - // next state should only by null when there is a circular reference between choices - if (!nextState || !isChoiceState(nextState)) { - return [branch]; - } else { - const nextBranches = nextState.Choices.map( - ({ Next, ...condition }) => { - // for each branch in the next state, AND with the current branch and assign the next state's Next. - return { ...ASL.and(branchCondition, condition), Next }; - } - ); - return nextState.Default - ? [...nextBranches, { ...branchCondition, Next: nextState.Default }] - : nextBranches; - } - }); - const defaultState = stateObj.Default - ? depthFirst(stateObj.Default) - : undefined; - - const [defaultValue, defaultBranches] = - !defaultState || !isChoiceState(defaultState) - ? [stateObj.Default, []] - : [defaultState.Default, defaultState.Choices]; - - const mergedChoice = { - ...stateObj, - Choices: [...branches, ...defaultBranches], - Default: defaultValue, - }; - - updatedStates[state] = mergedChoice; - return mergedChoice; - } - } - - /** - * Visit each transition in each state. - * Use the callback to update the transition name. - */ - function visitTransition( - state: State, - cb: (next: string) => string | undefined | void - ): State { - const cbOrNext = (next: string) => cb(next) ?? next; - if ("End" in state && state.End !== undefined) { - return state; - } - if (isChoiceState(state)) { - return { - ...state, - Choices: state.Choices.map((choice) => ({ - ...choice, - Next: cbOrNext(choice.Next), - })), - Default: state.Default ? cbOrNext(state.Default) : undefined, - }; - } else if ("Catch" in state) { - return { - ...state, - Catch: state.Catch?.map((_catch) => ({ - ..._catch, - Next: _catch.Next ? cbOrNext(_catch.Next) : _catch.Next, - })), - Next: state.Next ? cbOrNext(state.Next) : state.Next, - }; - } else if (!("Next" in state)) { - return state; - } - return { - ...state, - Next: state.Next ? cbOrNext(state.Next) : state.Next, - }; - } - - /** - * Finds the local state name in the nameMap. - * - * If the name contains the prefix `../` the search will start up a level. - * - * If a name is not found at the current level, the parent names will be searched. - * - * If no local name is found, the next value is returned as is. - */ - function rewriteStateTransitions( - state: ASLGraph.NodeState, - subStateNameMap: NameMap - ) { - return visitTransition(state, (next) => - updateTransition(next, subStateNameMap) - ); - - function updateTransition(next: string, nameMap: NameMap): string { - if (next.startsWith("../")) { - if (nameMap.parent) { - return updateTransition(next.substring(3), nameMap.parent); - } - return next.substring(3); - } else { - const find = (nameMap: NameMap): string => { - if (next in nameMap.localNames) { - return nameMap.localNames[next]!; - } else if (nameMap.parent) { - return find(nameMap.parent); - } else { - return next; - } - }; - return find(nameMap); - } - } - } - - /** - * Normalized an ASL state to just the output (constant or variable). - */ - export function getAslStateOutput( - state: ASLGraph.NodeResults - ): ASLGraph.Output { - return ASLGraph.isAslGraphOutput(state) ? state : state.output; - } - - /** - * Applies an {@link ASLGraph.Output} to a partial {@link Pass}. - * - * {@link ASLGraph.ConditionOutput} must be first turned into a {@link ASLGraph.JsonPath}. - */ - export function passWithInput( - pass: Omit, "Parameters" | "InputPath" | "Result"> & - CommonFields, - value: Exclude - ): Pass { - return { - ...pass, - ...(ASLGraph.isJsonPath(value) - ? { - InputPath: value.jsonPath, - } - : value.containsJsonPath - ? { - Parameters: value.value, - } - : { - Result: value.value, - }), - }; - } - - /** - * Applies an {@link ASLGraph.Output} to a partial {@link Task} - * - * {@link ASLGraph.ConditionOutput} must be first turned into a {@link ASLGraph.JsonPath}. - */ - export function taskWithInput( - task: Omit & CommonFields, - value: Exclude - ): Task { - return { - ...task, - ...(ASLGraph.isJsonPath(value) - ? { - InputPath: value.jsonPath, - } - : { - Parameters: value.value, - }), - }; - } - - /** - * Compare any two {@link ASLGraph.Output} values. - */ - export function compareOutputs( - leftOutput: ASLGraph.Output, - rightOutput: ASLGraph.Output, - operator: ASL.ValueComparisonOperators - ): Condition { - if ( - ASLGraph.isLiteralValue(leftOutput) && - ASLGraph.isLiteralValue(rightOutput) - ) { - return ((operator === "==" || operator === "===") && - leftOutput.value === rightOutput.value) || - ((operator === "!=" || operator === "!==") && - leftOutput.value !== rightOutput.value) || - (leftOutput.value !== null && - rightOutput.value !== null && - ((operator === ">" && leftOutput.value > rightOutput.value) || - (operator === "<" && leftOutput.value < rightOutput.value) || - (operator === "<=" && leftOutput.value <= rightOutput.value) || - (operator === ">=" && leftOutput.value >= rightOutput.value))) - ? ASL.trueCondition() - : ASL.falseCondition(); - } - - const [left, right] = - ASLGraph.isJsonPath(leftOutput) || ASLGraph.isConditionOutput(leftOutput) - ? [leftOutput, rightOutput] - : [ - rightOutput as ASLGraph.JsonPath | ASLGraph.ConditionOutput, - leftOutput, - ]; - // if the right is a variable and the left isn't, invert the operator - // 1 >= a -> a <= 1 - // a >= b -> a >= b - // a >= 1 -> a >= 1 - const op = leftOutput === left ? operator : invertBinaryOperator(operator); - - return ASLGraph.compare(left, right, op as any); - } - - export function compare( - left: ASLGraph.JsonPath | ASLGraph.ConditionOutput, - right: ASLGraph.Output, - operator: ASL.ValueComparisonOperators | "!=" | "!==" - ): Condition { - if ( - operator === "==" || - operator === "===" || - operator === ">" || - operator === "<" || - operator === ">=" || - operator === "<=" - ) { - if (ASLGraph.isConditionOutput(left)) { - return ( - ASLGraph.booleanCompare(left, right, operator) ?? ASL.falseCondition() - ); - } - const condition = ASL.or( - ASLGraph.nullCompare(left, right, operator), - ASLGraph.stringCompare(left, right, operator), - ASLGraph.booleanCompare(left, right, operator), - ASLGraph.numberCompare(left, right, operator) - ); - - if (ASLGraph.isJsonPath(right)) { - ASL.or( - // a === b while a and b are both not defined - ASL.not( - ASL.and(ASL.isPresent(left.jsonPath), ASL.isPresent(right.jsonPath)) - ), - // a !== undefined && b !== undefined - ASL.and( - ASL.isPresent(left.jsonPath), - ASL.isPresent(right.jsonPath), - // && a [op] b - condition - ) - ); - } - return ASL.and(ASL.isPresent(left.jsonPath), condition); - } else if (operator === "!=" || operator === "!==") { - return ASL.not(ASLGraph.compare(left, right, "==")); - } - - assertNever(operator); - } - - // Assumes the variable(s) are present and not null - export function stringCompare( - left: ASLGraph.JsonPath, - right: ASLGraph.Output, - operator: ASL.ValueComparisonOperators - ) { - if ( - ASLGraph.isJsonPath(right) || - (ASLGraph.isLiteralValue(right) && typeof right.value === "string") - ) { - return ASL.and( - ASL.isString(left.jsonPath), - ASLGraph.isJsonPath(right) - ? ASL.comparePathOfType( - left.jsonPath, - operator, - right.jsonPath, - "string" - ) - : ASL.compareValueOfType( - left.jsonPath, - operator, - right.value as string - ) - ); - } - return undefined; - } - - export function numberCompare( - left: ASLGraph.JsonPath, - right: ASLGraph.Output, - operator: ASL.ValueComparisonOperators - ) { - if ( - ASLGraph.isJsonPath(right) || - (ASLGraph.isLiteralValue(right) && typeof right.value === "number") - ) { - return ASL.and( - ASL.isNumeric(left.jsonPath), - ASLGraph.isJsonPath(right) - ? ASL.comparePathOfType( - left.jsonPath, - operator, - right.jsonPath, - "number" - ) - : ASL.compareValueOfType( - left.jsonPath, - operator, - right.value as number - ) - ); - } - return undefined; - } - - export function booleanCompare( - left: ASLGraph.JsonPath | ASLGraph.ConditionOutput, - right: ASLGraph.Output, - operator: ASL.ValueComparisonOperators - ) { - // (z == b) === (a ==c c) - if (ASLGraph.isConditionOutput(left)) { - if (operator === "===" || operator === "==") { - if (ASLGraph.isConditionOutput(right)) { - // (!left && !right) || (left && right) - return ASL.or( - ASL.and(ASL.not(left.condition), ASL.not(right.condition)), - ASL.and(left.condition, right.condition) - ); - } else if ( - ASLGraph.isLiteralValue(right) && - typeof right.value === "boolean" - ) { - // (a === b) === true - return right.value ? left.condition : ASL.not(left.condition); - } else if (ASLGraph.isJsonPath(right)) { - // (a === b) === c - return ASL.or( - ASL.and( - ASL.not(left.condition), - ASL.booleanEquals(right.jsonPath, false) - ), - ASL.and(left.condition, ASL.booleanEquals(right.jsonPath, true)) - ); - } - } - return undefined; - } - if (ASLGraph.isConditionOutput(right)) { - // a === (b === c) - return ASL.or( - ASL.and( - ASL.not(right.condition), - ASL.booleanEquals(left.jsonPath, false) - ), - ASL.and(right.condition, ASL.booleanEquals(left.jsonPath, true)) - ); - } - if (ASLGraph.isJsonPath(right) || typeof right.value === "boolean") { - return ASL.and( - ASL.isBoolean(left.jsonPath), - ASLGraph.isJsonPath(right) - ? ASL.comparePathOfType( - left.jsonPath, - operator, - right.jsonPath, - "boolean" - ) - : ASL.compareValueOfType( - left.jsonPath, - operator, - right.value as boolean - ) - ); - } - return undefined; - } - - export function nullCompare( - left: ASLGraph.JsonPath, - right: ASLGraph.Output, - operator: ASL.ValueComparisonOperators - ) { - if (operator === "==" || operator === "===") { - if (ASLGraph.isJsonPath(right)) { - return ASL.and(ASL.isNull(left.jsonPath), ASL.isNull(right.jsonPath)); - } else if (ASLGraph.isLiteralValue(right) && right.value === null) { - return ASL.isNull(left.jsonPath); - } - } - return undefined; - } - - /** - * Returns a object with the key formatted based on the contents of the value. - * in ASL, object keys that reference json path values must have a suffix of ".$" - * { "input.$": "$.value" } - */ - export function jsonAssignment( - key: string, - output: Exclude - ): Record { - return { - [ASLGraph.isJsonPath(output) ? `${key}.$` : key]: ASLGraph.isLiteralValue( - output - ) - ? output.value - : output.jsonPath, - }; - } - - export function isTruthyOutput(v: ASLGraph.Output): Condition { - return ASLGraph.isLiteralValue(v) - ? v.value - ? ASL.trueCondition() - : ASL.falseCondition() - : ASLGraph.isJsonPath(v) - ? ASL.isTruthy(v.jsonPath) - : v.condition; - } - - export function elementIn( - element: string | number, - targetJsonPath: ASLGraph.JsonPath - ): Condition { - const accessed = ASLGraph.accessConstant(targetJsonPath, element, true); - - if (ASLGraph.isLiteralValue(accessed)) { - return accessed.value === undefined - ? ASL.falseCondition() - : ASL.trueCondition(); - } else { - return ASL.isPresent(accessed.jsonPath); - } - } - - /** - * @param element - when true (or field is a number) the output json path will prefer to use the square bracket format. - * `$.obj[field]`. When false will prefer the dot format `$.obj.field`. - */ - export function accessConstant( - value: ASLGraph.Output, - field: string | number, - element: boolean - ): ASLGraph.JsonPath | ASLGraph.LiteralValue { - if (ASLGraph.isJsonPath(value)) { - return typeof field === "number" - ? { jsonPath: `${value.jsonPath}[${field}]` } - : element - ? { jsonPath: `${value.jsonPath}['${field}']` } - : { jsonPath: `${value.jsonPath}.${field}` }; - } - - if (ASLGraph.isLiteralValue(value) && value.value) { - const accessedValue = (() => { - if (Array.isArray(value.value)) { - if (typeof field === "number") { - return value.value[field]; - } - throw new SynthError( - ErrorCodes.StepFunctions_Invalid_collection_access, - "Accessor to an array must be a constant number" - ); - } else if (typeof value.value === "object") { - return value.value[field]; - } - throw new SynthError( - ErrorCodes.StepFunctions_Invalid_collection_access, - "Only a constant object or array may be accessed." - ); - })(); - - return typeof accessedValue === "string" && - (accessedValue.startsWith("$") || accessedValue.startsWith("States.")) - ? { jsonPath: accessedValue } - : { - value: accessedValue, - containsJsonPath: value.containsJsonPath, - }; - } - - throw new SynthError( - ErrorCodes.StepFunctions_Invalid_collection_access, - "Only a constant object or array may be accessed." - ); - } -} - export namespace ASL { export function isTruthy(v: string): Condition { return and( diff --git a/src/step-function.ts b/src/step-function.ts index cad41217..066bcde8 100644 --- a/src/step-function.ts +++ b/src/step-function.ts @@ -1465,11 +1465,6 @@ class BaseStandardStepFunction< .promise(); }, }, - unhandledContext: (kind, contextKind) => { - throw new Error( - `${kind} is only available in the ${ASL.ContextName} and ${VTL.ContextName} context, but was used in ${contextKind}.` - ); - }, }); } @@ -1643,3 +1638,6 @@ function getArgs(call: CallExpr) { } return executionArn; } + +// to prevent the closure serializer from trying to import all of functionless. +export const deploymentOnlyModule = true; diff --git a/test/__snapshots__/serialize.test.ts.snap b/test/__snapshots__/serialize.test.ts.snap index 4adfee7b..3a3fe9d7 100644 --- a/test/__snapshots__/serialize.test.ts.snap +++ b/test/__snapshots__/serialize.test.ts.snap @@ -14190,13 +14190,6 @@ __preWarmContext.cache = __preWarmContext_cache; __f1.kind = \\"StepFunction\\"; __f1.functionlessKind = \\"StepFunction\\"; __f7.kind = \\"StepFunction.describeExecution\\"; -var __asl_1 = {}; -var __asl_1_ASL = {ContextName: \\"Amazon States Language\\"}; -__asl_1.ASL = __asl_1_ASL; -var __vtl_1 = {}; -var __vtl_1_VTL = {ContextName: \\"Velocity Template\\"}; -__vtl_1.VTL = __vtl_1_VTL; -__f7.unhandledContext = __f9; __f1.describeExecution = __f7; var __f1_definition = {}; __f1_definition.StartAt = \\"Initialize Functionless Context\\"; @@ -14260,13 +14253,6 @@ function __f3(__0, __1) { return (function __computed(...args) { return c(args, preWarmContext); });; }).apply(undefined, undefined).apply(this, arguments); -}function __f9(__0, __1) { - return (function() { - let asl_1 = __asl_1; - let vtl_1 = __vtl_1; - - return ((kind, contextKind) => { throw new Error(\`\${kind} is only available in the \${asl_1.ASL.ContextName} and \${vtl_1.VTL.ContextName} context, but was used in \${contextKind}.\`); });; - }).apply(undefined, undefined).apply(this, arguments); }function __f0() { return (function() { let sfn = __f1; @@ -14296,13 +14282,6 @@ __preWarmContext.cache = __preWarmContext_cache; __f1.kind = \\"StepFunction\\"; __f1.functionlessKind = \\"StepFunction\\"; __f7.kind = \\"StepFunction.describeExecution\\"; -var __asl_1 = {}; -var __asl_1_ASL = { ContextName: \\"Amazon States Language\\" }; -__asl_1.ASL = __asl_1_ASL; -var __vtl_1 = {}; -var __vtl_1_VTL = { ContextName: \\"Velocity Template\\" }; -__vtl_1.VTL = __vtl_1_VTL; -__f7.unhandledContext = __f9; __f1.describeExecution = __f7; var __f1_definition = {}; __f1_definition.StartAt = \\"Initialize Functionless Context\\"; @@ -14402,16 +14381,6 @@ function __f7() { ; }.apply(void 0, void 0).apply(this, arguments); } -function __f9(__0, __1) { - return function() { - let asl_1 = __asl_1; - let vtl_1 = __vtl_1; - return (kind, contextKind) => { - throw new Error(\`\${kind} is only available in the \${asl_1.ASL.ContextName} and \${vtl_1.VTL.ContextName} context, but was used in \${contextKind}.\`); - }; - ; - }.apply(void 0, void 0).apply(this, arguments); -} function __f0() { return function() { let sfn = __f1;