chore(workflows, core-flows): Split workflows tooling and definitions (#5705)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { mergeData } from "../merge-data"
|
||||
import { WorkflowStepMiddlewareReturn } from "../pipe"
|
||||
|
||||
describe("merge", function () {
|
||||
it("should merge a new object from the source into a specify target", async function () {
|
||||
const source = {
|
||||
stringProp: "stringProp",
|
||||
anArray: ["anArray"],
|
||||
input: {
|
||||
test: "test",
|
||||
},
|
||||
another: {
|
||||
anotherTest: "anotherTest",
|
||||
},
|
||||
}
|
||||
|
||||
const result = (await mergeData(
|
||||
["input", "another", "stringProp", "anArray"],
|
||||
"payload"
|
||||
)({ data: source } as any)) as unknown as WorkflowStepMiddlewareReturn
|
||||
|
||||
expect(result).toEqual({
|
||||
alias: "payload",
|
||||
value: {
|
||||
...source.input,
|
||||
...source.another,
|
||||
anArray: source.anArray,
|
||||
stringProp: source.stringProp,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge a new object from the entire source into the resul object", async function () {
|
||||
const source = {
|
||||
stringProp: "stringProp",
|
||||
anArray: ["anArray"],
|
||||
input: {
|
||||
test: "test",
|
||||
},
|
||||
another: {
|
||||
anotherTest: "anotherTest",
|
||||
},
|
||||
}
|
||||
|
||||
const { value: result } = (await mergeData()({
|
||||
data: source,
|
||||
} as any)) as unknown as WorkflowStepMiddlewareReturn
|
||||
|
||||
expect(result).toEqual({
|
||||
...source.input,
|
||||
...source.another,
|
||||
anArray: source.anArray,
|
||||
stringProp: source.stringProp,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { pipe } from "../pipe"
|
||||
|
||||
describe("Pipe", function () {
|
||||
it("should evaluate the input object and append the values to the data object and return the result from the handler", async function () {
|
||||
const payload = { input: "input" }
|
||||
const output = { test: "test" }
|
||||
const invoke = {
|
||||
input: payload,
|
||||
step1: { ...payload, step1Data: { test: "test" } },
|
||||
step2: { ...payload, step2Data: { test: "test" } },
|
||||
}
|
||||
|
||||
const handler = jest.fn().mockImplementation(async () => output)
|
||||
const input = {
|
||||
inputAlias: "payload",
|
||||
invoke: [
|
||||
{
|
||||
from: "payload",
|
||||
alias: "input",
|
||||
},
|
||||
{
|
||||
from: "step1",
|
||||
alias: "previousDataStep1",
|
||||
},
|
||||
{
|
||||
from: "step2",
|
||||
alias: "previousDataStep2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await pipe(input, handler)({ invoke, payload } as any)
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
input: payload,
|
||||
previousDataStep1: invoke.step1,
|
||||
previousDataStep2: invoke.step2,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toEqual(output)
|
||||
})
|
||||
|
||||
it("should evaluate the input object and append the values to the data object using the merge and return the result from the handler", async function () {
|
||||
const payload = { input: "input" }
|
||||
const output = { test: "test" }
|
||||
const invoke = {
|
||||
input: payload,
|
||||
step1: { step1Data: { test: "test" } },
|
||||
step2: [{ test: "test" }],
|
||||
}
|
||||
|
||||
const handler = jest.fn().mockImplementation(async () => output)
|
||||
const input = {
|
||||
inputAlias: "payload",
|
||||
merge: true,
|
||||
invoke: [
|
||||
{
|
||||
from: "payload",
|
||||
},
|
||||
{
|
||||
from: "step1",
|
||||
},
|
||||
{
|
||||
from: "step2",
|
||||
alias: "step2Data",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await pipe(input, handler)({ invoke, payload } as any)
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
...payload,
|
||||
...invoke.step1,
|
||||
step2Data: invoke.step2,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toEqual(output)
|
||||
})
|
||||
|
||||
it("should evaluate the input object and append the values to the data object using the merge to store on the merge alias and return the result from the handler", async function () {
|
||||
const payload = { input: "input" }
|
||||
const output = { test: "test" }
|
||||
const invoke = {
|
||||
input: payload,
|
||||
step1: { step1Data: { test: "test" } },
|
||||
step2: { step2Data: { test: "test" } },
|
||||
}
|
||||
|
||||
const handler = jest.fn().mockImplementation(async () => output)
|
||||
const input = {
|
||||
inputAlias: "payload",
|
||||
mergeAlias: "mergedData",
|
||||
invoke: [
|
||||
{
|
||||
from: "payload",
|
||||
alias: "input",
|
||||
},
|
||||
{
|
||||
from: "step1",
|
||||
alias: "step1Data",
|
||||
},
|
||||
{
|
||||
from: "step2",
|
||||
alias: "step2Data",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await pipe(input, handler)({ invoke, payload } as any)
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
input: payload,
|
||||
step1Data: invoke.step1,
|
||||
step2Data: invoke.step2,
|
||||
mergedData: {
|
||||
...payload,
|
||||
...invoke.step1,
|
||||
...invoke.step2,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toEqual(output)
|
||||
})
|
||||
|
||||
it("should evaluate the input object and append the values to the data object using the merge to store on the merge alias from the merge from values and return the result from the handler", async function () {
|
||||
const payload = { input: "input" }
|
||||
const output = { test: "test" }
|
||||
const invoke = {
|
||||
input: payload,
|
||||
step1: { step1Data: { test: "test" } },
|
||||
step2: { step2Data: { test: "test" } },
|
||||
}
|
||||
|
||||
const handler = jest.fn().mockImplementation(async () => output)
|
||||
const input = {
|
||||
inputAlias: "payload",
|
||||
mergeAlias: "mergedData",
|
||||
mergeFrom: ["input", "step1Data"],
|
||||
invoke: [
|
||||
{
|
||||
from: "payload",
|
||||
alias: "input",
|
||||
},
|
||||
{
|
||||
from: "step1",
|
||||
alias: "step1Data",
|
||||
},
|
||||
{
|
||||
from: "step2",
|
||||
alias: "step2Data",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await pipe(input, handler)({ invoke, payload } as any)
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
input: payload,
|
||||
step1Data: invoke.step1,
|
||||
step2Data: invoke.step2,
|
||||
mergedData: {
|
||||
...payload,
|
||||
...invoke.step1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toEqual(output)
|
||||
})
|
||||
|
||||
it("should execute onComplete function if available but the output result shouldn't change", async function () {
|
||||
const payload = { input: "input" }
|
||||
const output = { test: "test" }
|
||||
const invoke = {
|
||||
input: payload,
|
||||
}
|
||||
|
||||
const onComplete = jest.fn(async ({ data }) => {
|
||||
data.__changed = true
|
||||
|
||||
return
|
||||
})
|
||||
|
||||
const handler = jest.fn().mockImplementation(async () => output)
|
||||
const input = {
|
||||
inputAlias: "payload",
|
||||
invoke: [
|
||||
{
|
||||
from: "payload",
|
||||
alias: "input",
|
||||
},
|
||||
],
|
||||
onComplete,
|
||||
}
|
||||
|
||||
const result = await pipe(input, handler)({ invoke, payload } as any)
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
input: payload,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(onComplete).toHaveBeenCalled()
|
||||
expect(result).toEqual(output)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { exportWorkflow } from "../workflow-export"
|
||||
|
||||
jest.mock("@medusajs/orchestration", () => {
|
||||
return {
|
||||
TransactionHandlerType: {
|
||||
INVOKE: "invoke",
|
||||
COMPENSATE: "compensate",
|
||||
},
|
||||
TransactionState: {
|
||||
FAILED: "failed",
|
||||
REVERTED: "reverted",
|
||||
},
|
||||
LocalWorkflow: jest.fn(() => {
|
||||
return {
|
||||
run: jest.fn(() => {
|
||||
return {
|
||||
getErrors: jest.fn(),
|
||||
getState: jest.fn(() => "done"),
|
||||
getContext: jest.fn(() => {
|
||||
return {
|
||||
invoke: { result_step: "invoke_test" },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Export Workflow", function () {
|
||||
it("should prepare the input data before initializing the transaction", async function () {
|
||||
let transformedInput
|
||||
const prepare = jest.fn().mockImplementation(async (data) => {
|
||||
data.__transformed = true
|
||||
transformedInput = data
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
const work = exportWorkflow("id" as any, "result_step", prepare)
|
||||
|
||||
const wfHandler = work()
|
||||
|
||||
const input = {
|
||||
test: "payload",
|
||||
}
|
||||
|
||||
const { result } = await wfHandler.run({
|
||||
input,
|
||||
})
|
||||
|
||||
expect(input).toEqual({
|
||||
test: "payload",
|
||||
})
|
||||
|
||||
expect(transformedInput).toEqual({
|
||||
test: "payload",
|
||||
__transformed: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual("invoke_test")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const emptyHandler: any = () => {}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./merge-data"
|
||||
export * from "./empty-handler"
|
||||
export * from "./pipe"
|
||||
export * from "./workflow-export"
|
||||
@@ -0,0 +1,49 @@
|
||||
import { PipelineHandler, WorkflowArguments } from "./pipe"
|
||||
import { isObject } from "@medusajs/utils"
|
||||
|
||||
/**
|
||||
* Pipe utils that merges data from an object into a new object.
|
||||
* The new object will have a target key with the merged data from the keys if specified.
|
||||
* @param keys
|
||||
* @param target
|
||||
*/
|
||||
export function mergeData<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
TKeys extends keyof T = keyof T,
|
||||
Target extends "payload" | string = string
|
||||
>(keys: TKeys[] = [], target?: Target): PipelineHandler {
|
||||
return async function ({ data }: WorkflowArguments<T>) {
|
||||
const workingKeys = (keys.length ? keys : Object.keys(data)) as TKeys[]
|
||||
const value = workingKeys.reduce((acc, key) => {
|
||||
let targetAcc = { ...(target ? acc[target as string] : acc) }
|
||||
targetAcc ??= {}
|
||||
|
||||
if (Array.isArray(data[key as string])) {
|
||||
targetAcc[key as string] = data[key as string]
|
||||
} else if (isObject(data[key as string])) {
|
||||
targetAcc = {
|
||||
...targetAcc,
|
||||
...(data[key as string] as object),
|
||||
}
|
||||
} else {
|
||||
targetAcc[key as string] = data[key as string]
|
||||
}
|
||||
|
||||
if (target) {
|
||||
acc[target as string] = {
|
||||
...acc[target as string],
|
||||
...targetAcc,
|
||||
}
|
||||
} else {
|
||||
acc = targetAcc
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
alias: target,
|
||||
value: target ? value[target as string] : value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
DistributedTransaction,
|
||||
TransactionMetadata,
|
||||
WorkflowStepHandler,
|
||||
} from "@medusajs/orchestration"
|
||||
import { Context, MedusaContainer, SharedContext } from "@medusajs/types"
|
||||
import { mergeData } from "./merge-data"
|
||||
|
||||
export type WorkflowStepMiddlewareReturn = {
|
||||
alias?: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export type WorkflowStepMiddlewareInput = {
|
||||
from: string
|
||||
alias?: string
|
||||
}
|
||||
|
||||
interface PipelineInput {
|
||||
/**
|
||||
* The alias of the input data to store in
|
||||
*/
|
||||
inputAlias?: string
|
||||
/**
|
||||
* Descriptors to get the data from
|
||||
*/
|
||||
invoke?: WorkflowStepMiddlewareInput | WorkflowStepMiddlewareInput[]
|
||||
compensate?: WorkflowStepMiddlewareInput | WorkflowStepMiddlewareInput[]
|
||||
onComplete?: (args: WorkflowOnCompleteArguments) => Promise<void>
|
||||
/**
|
||||
* Apply the data merging
|
||||
*/
|
||||
merge?: boolean
|
||||
/**
|
||||
* Store the merged data in a new key, if this is present no need to set merge: true
|
||||
*/
|
||||
mergeAlias?: string
|
||||
/**
|
||||
* Store the merged data from the chosen aliases, if this is present no need to set merge: true
|
||||
*/
|
||||
mergeFrom?: string[]
|
||||
}
|
||||
|
||||
export type WorkflowArguments<T = any> = {
|
||||
container: MedusaContainer
|
||||
payload: unknown
|
||||
data: T
|
||||
metadata: TransactionMetadata
|
||||
context: Context | SharedContext
|
||||
}
|
||||
|
||||
export type WorkflowOnCompleteArguments<T = any> = {
|
||||
container: MedusaContainer
|
||||
payload: unknown
|
||||
data: T
|
||||
metadata: TransactionMetadata
|
||||
transaction: DistributedTransaction
|
||||
context: Context | SharedContext
|
||||
}
|
||||
|
||||
export type PipelineHandler<T extends any = undefined> = (
|
||||
args: WorkflowArguments
|
||||
) => Promise<
|
||||
T extends undefined
|
||||
? WorkflowStepMiddlewareReturn | WorkflowStepMiddlewareReturn[]
|
||||
: T
|
||||
>
|
||||
|
||||
export function pipe<T>(
|
||||
input: PipelineInput,
|
||||
...functions: [...PipelineHandler[], PipelineHandler<T>]
|
||||
): WorkflowStepHandler {
|
||||
// Apply the aggregator just before the last handler
|
||||
if (
|
||||
(input.merge || input.mergeAlias || input.mergeFrom) &&
|
||||
functions.length
|
||||
) {
|
||||
const handler = functions.pop()!
|
||||
functions.push(mergeData(input.mergeFrom, input.mergeAlias), handler)
|
||||
}
|
||||
|
||||
return async ({
|
||||
container,
|
||||
payload,
|
||||
invoke,
|
||||
compensate,
|
||||
metadata,
|
||||
transaction,
|
||||
context,
|
||||
}) => {
|
||||
let data = {}
|
||||
|
||||
const original = {
|
||||
invoke: invoke ?? {},
|
||||
compensate: compensate ?? {},
|
||||
}
|
||||
|
||||
if (input.inputAlias) {
|
||||
Object.assign(original.invoke, { [input.inputAlias]: payload })
|
||||
}
|
||||
|
||||
const dataKeys = ["invoke", "compensate"]
|
||||
for (const key of dataKeys) {
|
||||
if (!input[key]) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!Array.isArray(input[key])) {
|
||||
input[key] = [input[key]]
|
||||
}
|
||||
|
||||
for (const action of input[key]) {
|
||||
if (action.alias) {
|
||||
data[action.alias] = original[key][action.from]
|
||||
} else {
|
||||
data[action.from] = original[key][action.from]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalResult
|
||||
for (const fn of functions) {
|
||||
let result = await fn({
|
||||
container,
|
||||
payload,
|
||||
data,
|
||||
metadata,
|
||||
context: context as Context,
|
||||
})
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const action of result) {
|
||||
if (action?.alias) {
|
||||
data[action.alias] = action.value
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
result &&
|
||||
"alias" in (result as WorkflowStepMiddlewareReturn)
|
||||
) {
|
||||
if ((result as WorkflowStepMiddlewareReturn).alias) {
|
||||
data[(result as WorkflowStepMiddlewareReturn).alias!] = (
|
||||
result as WorkflowStepMiddlewareReturn
|
||||
).value
|
||||
} else {
|
||||
data = (result as WorkflowStepMiddlewareReturn).value
|
||||
}
|
||||
}
|
||||
|
||||
finalResult = result
|
||||
}
|
||||
|
||||
if (typeof input.onComplete === "function") {
|
||||
const dataCopy = JSON.parse(JSON.stringify(data))
|
||||
await input.onComplete({
|
||||
container,
|
||||
payload,
|
||||
data: dataCopy,
|
||||
metadata,
|
||||
transaction,
|
||||
context: context as Context,
|
||||
})
|
||||
}
|
||||
|
||||
return finalResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
DistributedTransaction,
|
||||
LocalWorkflow,
|
||||
TransactionHandlerType,
|
||||
TransactionState,
|
||||
TransactionStepError,
|
||||
} from "@medusajs/orchestration"
|
||||
import { Context, LoadedModule, MedusaContainer } from "@medusajs/types"
|
||||
|
||||
import { MedusaModule } from "@medusajs/modules-sdk"
|
||||
import { EOL } from "os"
|
||||
import { ulid } from "ulid"
|
||||
import { SymbolWorkflowWorkflowData } from "../utils/composer"
|
||||
|
||||
export type FlowRunOptions<TData = unknown> = {
|
||||
input?: TData
|
||||
context?: Context
|
||||
resultFrom?: string | string[]
|
||||
throwOnError?: boolean
|
||||
}
|
||||
|
||||
export type WorkflowResult<TResult = unknown> = {
|
||||
errors: TransactionStepError[]
|
||||
transaction: DistributedTransaction
|
||||
result: TResult
|
||||
}
|
||||
|
||||
export const exportWorkflow = <TData = unknown, TResult = unknown>(
|
||||
workflowId: string,
|
||||
defaultResult?: string,
|
||||
dataPreparation?: (data: TData) => Promise<unknown>
|
||||
) => {
|
||||
return function <TDataOverride = undefined, TResultOverride = undefined>(
|
||||
container?: LoadedModule[] | MedusaContainer
|
||||
): Omit<LocalWorkflow, "run"> & {
|
||||
run: (
|
||||
args?: FlowRunOptions<
|
||||
TDataOverride extends undefined ? TData : TDataOverride
|
||||
>
|
||||
) => Promise<
|
||||
WorkflowResult<
|
||||
TResultOverride extends undefined ? TResult : TResultOverride
|
||||
>
|
||||
>
|
||||
} {
|
||||
if (!container) {
|
||||
container = MedusaModule.getLoadedModules().map(
|
||||
(mod) => Object.values(mod)[0]
|
||||
)
|
||||
}
|
||||
|
||||
const flow = new LocalWorkflow(workflowId, container)
|
||||
|
||||
const originalRun = flow.run.bind(flow)
|
||||
const newRun = async (
|
||||
{ input, context, throwOnError, resultFrom }: FlowRunOptions = {
|
||||
throwOnError: true,
|
||||
resultFrom: defaultResult,
|
||||
}
|
||||
) => {
|
||||
resultFrom ??= defaultResult
|
||||
throwOnError ??= true
|
||||
|
||||
if (typeof dataPreparation === "function") {
|
||||
try {
|
||||
const copyInput = input ? JSON.parse(JSON.stringify(input)) : input
|
||||
input = await dataPreparation(copyInput as TData)
|
||||
} catch (err) {
|
||||
if (throwOnError) {
|
||||
throw new Error(
|
||||
`Data preparation failed: ${err.message}${EOL}${err.stack}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
errors: [err],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transaction = await originalRun(
|
||||
context?.transactionId ?? ulid(),
|
||||
input,
|
||||
context
|
||||
)
|
||||
|
||||
const errors = transaction.getErrors(TransactionHandlerType.INVOKE)
|
||||
|
||||
const failedStatus = [TransactionState.FAILED, TransactionState.REVERTED]
|
||||
if (failedStatus.includes(transaction.getState()) && throwOnError) {
|
||||
const errorMessage = errors
|
||||
?.map((err) => `${err.error?.message}${EOL}${err.error?.stack}`)
|
||||
?.join(`${EOL}`)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
let result: any = undefined
|
||||
|
||||
if (resultFrom) {
|
||||
if (Array.isArray(resultFrom)) {
|
||||
result = resultFrom.map((from) => {
|
||||
const res = transaction.getContext().invoke?.[from]
|
||||
return res?.__type === SymbolWorkflowWorkflowData ? res.output : res
|
||||
})
|
||||
} else {
|
||||
const res = transaction.getContext().invoke?.[resultFrom]
|
||||
result = res?.__type === SymbolWorkflowWorkflowData ? res.output : res
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
transaction,
|
||||
result,
|
||||
}
|
||||
}
|
||||
flow.run = newRun as any
|
||||
|
||||
return flow as unknown as LocalWorkflow & {
|
||||
run: (
|
||||
args?: FlowRunOptions<
|
||||
TDataOverride extends undefined ? TData : TDataOverride
|
||||
>
|
||||
) => Promise<
|
||||
WorkflowResult<
|
||||
TResultOverride extends undefined ? TResult : TResultOverride
|
||||
>
|
||||
>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user