Feat: TO variant creation (#3097)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
ProductService.update no longer create product variants. It was moved to the endpoint handler update-product.ts
|
||||||
@@ -3,6 +3,7 @@ import { IsBoolean, IsNumber, IsOptional, IsString } from "class-validator"
|
|||||||
|
|
||||||
import { IInventoryService } from "../../../../interfaces"
|
import { IInventoryService } from "../../../../interfaces"
|
||||||
import { FindParams } from "../../../../types/common"
|
import { FindParams } from "../../../../types/common"
|
||||||
|
import { EntityManager } from "typeorm"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /inventory-items/{id}
|
* @oas [post] /inventory-items/{id}
|
||||||
@@ -71,11 +72,16 @@ export default async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const inventoryService: IInventoryService =
|
const inventoryService: IInventoryService =
|
||||||
req.scope.resolve("inventoryService")
|
req.scope.resolve("inventoryService")
|
||||||
|
const manager: EntityManager = req.scope.resolve("manager")
|
||||||
|
|
||||||
await inventoryService.updateInventoryItem(
|
await manager.transaction(async (transactionManager) => {
|
||||||
id,
|
await inventoryService
|
||||||
req.validatedBody as AdminPostInventoryItemsInventoryItemReq
|
.withTransaction(transactionManager)
|
||||||
)
|
.updateInventoryItem(
|
||||||
|
id,
|
||||||
|
req.validatedBody as AdminPostInventoryItemsInventoryItemReq
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const inventoryItem = await inventoryService.retrieveInventoryItem(
|
const inventoryItem = await inventoryService.retrieveInventoryItem(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IsOptional, IsNumber } from "class-validator"
|
|||||||
|
|
||||||
import { IInventoryService } from "../../../../interfaces"
|
import { IInventoryService } from "../../../../interfaces"
|
||||||
import { FindParams } from "../../../../types/common"
|
import { FindParams } from "../../../../types/common"
|
||||||
|
import { EntityManager } from "typeorm"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /inventory-items/{id}/location-levels/{location_id}
|
* @oas [post] /inventory-items/{id}/location-levels/{location_id}
|
||||||
@@ -72,11 +73,16 @@ export default async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const inventoryService: IInventoryService =
|
const inventoryService: IInventoryService =
|
||||||
req.scope.resolve("inventoryService")
|
req.scope.resolve("inventoryService")
|
||||||
|
const manager: EntityManager = req.scope.resolve("manager")
|
||||||
|
|
||||||
const validatedBody =
|
const validatedBody =
|
||||||
req.validatedBody as AdminPostInventoryItemsItemLocationLevelsLevelReq
|
req.validatedBody as AdminPostInventoryItemsItemLocationLevelsLevelReq
|
||||||
|
|
||||||
await inventoryService.updateInventoryLevel(id, location_id, validatedBody)
|
await manager.transaction(async (transactionManager) => {
|
||||||
|
await inventoryService
|
||||||
|
.withTransaction(transactionManager)
|
||||||
|
.updateInventoryLevel(id, location_id, validatedBody)
|
||||||
|
})
|
||||||
|
|
||||||
const inventoryItem = await inventoryService.retrieveInventoryItem(
|
const inventoryItem = await inventoryService.retrieveInventoryItem(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { IdMap } from "medusa-test-utils"
|
import { IdMap } from "medusa-test-utils"
|
||||||
import { request } from "../../../../../helpers/test-request"
|
import { request } from "../../../../../helpers/test-request"
|
||||||
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
|
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
|
||||||
|
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
|
||||||
|
import { EventBusServiceMock } from "../../../../../services/__mocks__/event-bus"
|
||||||
|
|
||||||
describe("POST /admin/products/:id", () => {
|
describe("POST /admin/products/:id", () => {
|
||||||
describe("successfully updates a product", () => {
|
describe("successfully updates a product", () => {
|
||||||
@@ -9,12 +11,17 @@ describe("POST /admin/products/:id", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
subject = await request(
|
subject = await request(
|
||||||
"POST",
|
"POST",
|
||||||
`/admin/products/${IdMap.getId("product1")}`,
|
`/admin/products/${IdMap.getId("multipleVariants")}`,
|
||||||
{
|
{
|
||||||
payload: {
|
payload: {
|
||||||
title: "Product 1",
|
title: "Product 1",
|
||||||
description: "Updated test description",
|
description: "Updated test description",
|
||||||
handle: "handle",
|
handle: "handle",
|
||||||
|
variants: [
|
||||||
|
{ id: IdMap.getId("variant_1"), title: "Green" },
|
||||||
|
{ title: "Blue" },
|
||||||
|
{ title: "Yellow" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
adminSession: {
|
adminSession: {
|
||||||
jwt: {
|
jwt: {
|
||||||
@@ -32,7 +39,7 @@ describe("POST /admin/products/:id", () => {
|
|||||||
it("calls update", () => {
|
it("calls update", () => {
|
||||||
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
|
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
|
||||||
expect(ProductServiceMock.update).toHaveBeenCalledWith(
|
expect(ProductServiceMock.update).toHaveBeenCalledWith(
|
||||||
IdMap.getId("product1"),
|
IdMap.getId("multipleVariants"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: "Product 1",
|
title: "Product 1",
|
||||||
description: "Updated test description",
|
description: "Updated test description",
|
||||||
@@ -40,51 +47,35 @@ describe("POST /admin/products/:id", () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("successfully updates variants and create new ones", async () => {
|
||||||
|
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(2)
|
||||||
|
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1)
|
||||||
|
expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("handles failed update operation", () => {
|
describe("handles failed update operation", () => {
|
||||||
it("throws if metadata is to be updated", async () => {
|
it("throws on wrong variant in update", async () => {
|
||||||
try {
|
const subject = await request(
|
||||||
await request("POST", `/admin/products/${IdMap.getId("product1")}`, {
|
"POST",
|
||||||
|
`/admin/products/${IdMap.getId("variantsWithPrices")}`,
|
||||||
|
{
|
||||||
payload: {
|
payload: {
|
||||||
_id: IdMap.getId("product1"),
|
|
||||||
title: "Product 1",
|
title: "Product 1",
|
||||||
metadata: "Test Description",
|
variants: [{ id: "test_321", title: "Green" }],
|
||||||
},
|
},
|
||||||
adminSession: {
|
adminSession: {
|
||||||
jwt: {
|
jwt: {
|
||||||
userId: IdMap.getId("admin_user"),
|
userId: IdMap.getId("admin_user"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
} catch (error) {
|
)
|
||||||
expect(error.status).toEqual(400)
|
expect(subject.status).toEqual(404)
|
||||||
expect(error.message).toEqual(
|
expect(subject.error.text).toEqual(
|
||||||
"Use setMetadata to update metadata fields"
|
`{"type":"not_found","message":"Variant with id: test_321 is not associated with this product"}`
|
||||||
)
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("throws if variants is to be updated", async () => {
|
|
||||||
try {
|
|
||||||
await request("POST", `/admin/products/${IdMap.getId("product1")}`, {
|
|
||||||
payload: {
|
|
||||||
_id: IdMap.getId("product1"),
|
|
||||||
title: "Product 1",
|
|
||||||
metadata: "Test Description",
|
|
||||||
},
|
|
||||||
adminSession: {
|
|
||||||
jwt: {
|
|
||||||
userId: IdMap.getId("admin_user"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
expect(error.status).toEqual(400)
|
|
||||||
expect(error.message).toEqual(
|
|
||||||
"Use addVariant, reorderVariants, removeVariant to update Product Variants"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { defaultAdminProductFields, defaultAdminProductRelations } from "."
|
|||||||
import {
|
import {
|
||||||
PricingService,
|
PricingService,
|
||||||
ProductService,
|
ProductService,
|
||||||
|
ProductVariantInventoryService,
|
||||||
ProductVariantService,
|
ProductVariantService,
|
||||||
ShippingProfileService,
|
ShippingProfileService,
|
||||||
} from "../../../../services"
|
} from "../../../../services"
|
||||||
@@ -32,6 +33,14 @@ import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-cha
|
|||||||
import { ProductStatus } from "../../../../models"
|
import { ProductStatus } from "../../../../models"
|
||||||
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
|
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
|
import { IInventoryService } from "../../../../interfaces"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createVariantTransaction,
|
||||||
|
revertVariantTransaction,
|
||||||
|
} from "./transaction/create-product-variant"
|
||||||
|
import { DistributedTransaction } from "../../../../utils/transaction"
|
||||||
|
import { Logger } from "../../../../types/global"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /products
|
* @oas [post] /products
|
||||||
@@ -98,6 +107,7 @@ import { validator } from "../../../../utils/validator"
|
|||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const validated = await validator(AdminPostProductsReq, req.body)
|
const validated = await validator(AdminPostProductsReq, req.body)
|
||||||
|
|
||||||
|
const logger: Logger = req.scope.resolve("logger")
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
const productService: ProductService = req.scope.resolve("productService")
|
||||||
const pricingService: PricingService = req.scope.resolve("pricingService")
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const productVariantService: ProductVariantService = req.scope.resolve(
|
const productVariantService: ProductVariantService = req.scope.resolve(
|
||||||
@@ -106,6 +116,10 @@ export default async (req, res) => {
|
|||||||
const shippingProfileService: ShippingProfileService = req.scope.resolve(
|
const shippingProfileService: ShippingProfileService = req.scope.resolve(
|
||||||
"shippingProfileService"
|
"shippingProfileService"
|
||||||
)
|
)
|
||||||
|
const productVariantInventoryService: ProductVariantInventoryService =
|
||||||
|
req.scope.resolve("productVariantInventoryService")
|
||||||
|
const inventoryService: IInventoryService | undefined =
|
||||||
|
req.scope.resolve("inventoryService")
|
||||||
|
|
||||||
const entityManager: EntityManager = req.scope.resolve("manager")
|
const entityManager: EntityManager = req.scope.resolve("manager")
|
||||||
|
|
||||||
@@ -134,8 +148,8 @@ export default async (req, res) => {
|
|||||||
.create({ ...validated, profile_id: shippingProfile.id })
|
.create({ ...validated, profile_id: shippingProfile.id })
|
||||||
|
|
||||||
if (variants) {
|
if (variants) {
|
||||||
for (const [i, variant] of variants.entries()) {
|
for (const [index, variant] of variants.entries()) {
|
||||||
variant["variant_rank"] = i
|
variant["variant_rank"] = index
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionIds =
|
const optionIds =
|
||||||
@@ -143,22 +157,48 @@ export default async (req, res) => {
|
|||||||
(o) => newProduct.options.find((newO) => newO.title === o.title)?.id
|
(o) => newProduct.options.find((newO) => newO.title === o.title)?.id
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
await Promise.all(
|
const allVariantTransactions: DistributedTransaction[] = []
|
||||||
variants.map(async (v) => {
|
const transactionDependencies = {
|
||||||
const variant = {
|
manager,
|
||||||
...v,
|
inventoryService,
|
||||||
options:
|
productVariantInventoryService,
|
||||||
v?.options?.map((o, index) => ({
|
productVariantService,
|
||||||
...o,
|
}
|
||||||
option_id: optionIds[index],
|
|
||||||
})) || [],
|
|
||||||
}
|
|
||||||
|
|
||||||
await productVariantService
|
try {
|
||||||
.withTransaction(manager)
|
await Promise.all(
|
||||||
.create(newProduct.id, variant as CreateProductVariantInput)
|
variants.map(async (variant) => {
|
||||||
})
|
const options =
|
||||||
)
|
variant?.options?.map((option, index) => ({
|
||||||
|
...option,
|
||||||
|
option_id: optionIds[index],
|
||||||
|
})) || []
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
...variant,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
|
||||||
|
const varTransation = await createVariantTransaction(
|
||||||
|
transactionDependencies,
|
||||||
|
newProduct.id,
|
||||||
|
input as CreateProductVariantInput
|
||||||
|
)
|
||||||
|
allVariantTransactions.push(varTransation)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
await Promise.all(
|
||||||
|
allVariantTransactions.map(async (transaction) => {
|
||||||
|
await revertVariantTransaction(
|
||||||
|
transactionDependencies,
|
||||||
|
transaction
|
||||||
|
).catch(() => logger.warn("Transaction couldn't be reverted."))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newProduct
|
return newProduct
|
||||||
|
|||||||
@@ -22,18 +22,10 @@ import {
|
|||||||
} from "../../../../types/product-variant"
|
} from "../../../../types/product-variant"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
|
|
||||||
import {
|
|
||||||
TransactionHandlerType,
|
|
||||||
TransactionOrchestrator,
|
|
||||||
TransactionPayload,
|
|
||||||
TransactionState,
|
|
||||||
TransactionStepsDefinition,
|
|
||||||
} from "../../../../utils/transaction"
|
|
||||||
|
|
||||||
import { ulid } from "ulid"
|
|
||||||
import { MedusaError } from "medusa-core-utils"
|
|
||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
|
|
||||||
|
import { createVariantTransaction } from "./transaction/create-product-variant"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /products/{id}/variants
|
* @oas [post] /products/{id}/variants
|
||||||
* operationId: "PostProductsProductVariants"
|
* operationId: "PostProductsProductVariants"
|
||||||
@@ -122,47 +114,6 @@ import { EntityManager } from "typeorm"
|
|||||||
* $ref: "#/components/responses/500_error"
|
* $ref: "#/components/responses/500_error"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
enum actions {
|
|
||||||
createVariant = "createVariant",
|
|
||||||
createInventoryItem = "createInventoryItem",
|
|
||||||
attachInventoryItem = "attachInventoryItem",
|
|
||||||
}
|
|
||||||
|
|
||||||
const simpleFlow: TransactionStepsDefinition = {
|
|
||||||
next: {
|
|
||||||
action: actions.createVariant,
|
|
||||||
maxRetries: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const flowWithInventory: TransactionStepsDefinition = {
|
|
||||||
next: {
|
|
||||||
action: actions.createVariant,
|
|
||||||
forwardResponse: true,
|
|
||||||
maxRetries: 0,
|
|
||||||
next: {
|
|
||||||
action: actions.createInventoryItem,
|
|
||||||
forwardResponse: true,
|
|
||||||
maxRetries: 0,
|
|
||||||
next: {
|
|
||||||
action: actions.attachInventoryItem,
|
|
||||||
noCompensation: true,
|
|
||||||
maxRetries: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const createSimpleVariantStrategy = new TransactionOrchestrator(
|
|
||||||
"create-variant",
|
|
||||||
simpleFlow
|
|
||||||
)
|
|
||||||
|
|
||||||
const createVariantStrategyWithInventory = new TransactionOrchestrator(
|
|
||||||
"create-variant-with-inventory",
|
|
||||||
flowWithInventory
|
|
||||||
)
|
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
|
|
||||||
@@ -179,129 +130,19 @@ export default async (req, res) => {
|
|||||||
"productVariantService"
|
"productVariantService"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createdId: Record<string, string | null> = {
|
|
||||||
variant: null,
|
|
||||||
inventoryItem: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const manager: EntityManager = req.scope.resolve("manager")
|
const manager: EntityManager = req.scope.resolve("manager")
|
||||||
|
|
||||||
await manager.transaction(async (transactionManager) => {
|
await manager.transaction(async (transactionManager) => {
|
||||||
const inventoryServiceTx =
|
await createVariantTransaction(
|
||||||
inventoryService?.withTransaction(transactionManager)
|
{
|
||||||
|
manager: transactionManager,
|
||||||
const productVariantInventoryServiceTx =
|
inventoryService,
|
||||||
productVariantInventoryService.withTransaction(transactionManager)
|
productVariantInventoryService,
|
||||||
|
productVariantService,
|
||||||
const productVariantServiceTx =
|
},
|
||||||
productVariantService.withTransaction(transactionManager)
|
id,
|
||||||
|
validated as CreateProductVariantInput
|
||||||
async function createVariant() {
|
|
||||||
const variant = await productVariantServiceTx.create(
|
|
||||||
id,
|
|
||||||
validated as CreateProductVariantInput
|
|
||||||
)
|
|
||||||
|
|
||||||
createdId.variant = variant.id
|
|
||||||
|
|
||||||
return { variant }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeVariant() {
|
|
||||||
if (createdId.variant) {
|
|
||||||
await productVariantServiceTx.delete(createdId.variant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInventoryItem(variant) {
|
|
||||||
if (!validated.manage_inventory) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const inventoryItem = await inventoryServiceTx!.createInventoryItem({
|
|
||||||
sku: validated.sku,
|
|
||||||
origin_country: validated.origin_country,
|
|
||||||
hs_code: validated.hs_code,
|
|
||||||
mid_code: validated.mid_code,
|
|
||||||
material: validated.material,
|
|
||||||
weight: validated.weight,
|
|
||||||
length: validated.length,
|
|
||||||
height: validated.height,
|
|
||||||
width: validated.width,
|
|
||||||
})
|
|
||||||
|
|
||||||
createdId.inventoryItem = inventoryItem.id
|
|
||||||
|
|
||||||
return { variant, inventoryItem }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeInventoryItem() {
|
|
||||||
if (createdId.inventoryItem) {
|
|
||||||
await inventoryServiceTx!.deleteInventoryItem(createdId.inventoryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachInventoryItem(variant, inventoryItem) {
|
|
||||||
if (!validated.manage_inventory) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await productVariantInventoryServiceTx.attachInventoryItem(
|
|
||||||
variant.id,
|
|
||||||
inventoryItem.id,
|
|
||||||
validated.inventory_quantity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function transactionHandler(
|
|
||||||
actionId: string,
|
|
||||||
type: TransactionHandlerType,
|
|
||||||
payload: TransactionPayload
|
|
||||||
) {
|
|
||||||
const command = {
|
|
||||||
[actions.createVariant]: {
|
|
||||||
[TransactionHandlerType.INVOKE]: async () => {
|
|
||||||
return await createVariant()
|
|
||||||
},
|
|
||||||
[TransactionHandlerType.COMPENSATE]: async () => {
|
|
||||||
await removeVariant()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[actions.createInventoryItem]: {
|
|
||||||
[TransactionHandlerType.INVOKE]: async (data) => {
|
|
||||||
const { variant } = data._response ?? {}
|
|
||||||
return await createInventoryItem(variant)
|
|
||||||
},
|
|
||||||
[TransactionHandlerType.COMPENSATE]: async () => {
|
|
||||||
await removeInventoryItem()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[actions.attachInventoryItem]: {
|
|
||||||
[TransactionHandlerType.INVOKE]: async (data) => {
|
|
||||||
const { variant, inventoryItem } = data._response ?? {}
|
|
||||||
return await attachInventoryItem(variant, inventoryItem)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return command[actionId][type](payload.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const strategy = inventoryService
|
|
||||||
? createVariantStrategyWithInventory
|
|
||||||
: createSimpleVariantStrategy
|
|
||||||
|
|
||||||
const transaction = await strategy.beginTransaction(
|
|
||||||
ulid(),
|
|
||||||
transactionHandler,
|
|
||||||
validated
|
|
||||||
)
|
)
|
||||||
await strategy.resume(transaction)
|
|
||||||
|
|
||||||
if (transaction.getState() !== TransactionState.DONE) {
|
|
||||||
throw new MedusaError(
|
|
||||||
MedusaError.Types.INVALID_DATA,
|
|
||||||
transaction.errors.map((err) => err.error?.message).join("\n")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
const productService: ProductService = req.scope.resolve("productService")
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import {
|
||||||
|
DistributedTransaction,
|
||||||
|
TransactionHandlerType,
|
||||||
|
TransactionOrchestrator,
|
||||||
|
TransactionPayload,
|
||||||
|
TransactionState,
|
||||||
|
TransactionStepsDefinition,
|
||||||
|
} from "../../../../../utils/transaction"
|
||||||
|
import { ulid } from "ulid"
|
||||||
|
import { EntityManager } from "typeorm"
|
||||||
|
import { IInventoryService } from "../../../../../interfaces"
|
||||||
|
import {
|
||||||
|
ProductVariantInventoryService,
|
||||||
|
ProductVariantService,
|
||||||
|
} from "../../../../../services"
|
||||||
|
import { CreateProductVariantInput } from "../../../../../types/product-variant"
|
||||||
|
import { InventoryItemDTO } from "../../../../../types/inventory"
|
||||||
|
import { ProductVariant } from "../../../../../models"
|
||||||
|
import { MedusaError } from "medusa-core-utils"
|
||||||
|
|
||||||
|
enum actions {
|
||||||
|
createVariant = "createVariant",
|
||||||
|
createInventoryItem = "createInventoryItem",
|
||||||
|
attachInventoryItem = "attachInventoryItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
const simpleFlow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: actions.createVariant,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowWithInventory: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: actions.createVariant,
|
||||||
|
saveResponse: true,
|
||||||
|
next: {
|
||||||
|
action: actions.createInventoryItem,
|
||||||
|
saveResponse: true,
|
||||||
|
next: {
|
||||||
|
action: actions.attachInventoryItem,
|
||||||
|
noCompensation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSimpleVariantStrategy = new TransactionOrchestrator(
|
||||||
|
"create-variant",
|
||||||
|
simpleFlow
|
||||||
|
)
|
||||||
|
|
||||||
|
const createVariantStrategyWithInventory = new TransactionOrchestrator(
|
||||||
|
"create-variant-with-inventory",
|
||||||
|
flowWithInventory
|
||||||
|
)
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
manager: EntityManager
|
||||||
|
productVariantService: ProductVariantService
|
||||||
|
productVariantInventoryService: ProductVariantInventoryService
|
||||||
|
inventoryService?: IInventoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVariantTransaction = async (
|
||||||
|
dependencies: InjectedDependencies,
|
||||||
|
productId: string,
|
||||||
|
input: CreateProductVariantInput
|
||||||
|
): Promise<DistributedTransaction> => {
|
||||||
|
const {
|
||||||
|
manager,
|
||||||
|
productVariantService,
|
||||||
|
inventoryService,
|
||||||
|
productVariantInventoryService,
|
||||||
|
} = dependencies
|
||||||
|
|
||||||
|
const inventoryServiceTx = inventoryService?.withTransaction(manager)
|
||||||
|
|
||||||
|
const productVariantInventoryServiceTx =
|
||||||
|
productVariantInventoryService.withTransaction(manager)
|
||||||
|
|
||||||
|
const productVariantServiceTx = productVariantService.withTransaction(manager)
|
||||||
|
|
||||||
|
async function createVariant(variantInput: CreateProductVariantInput) {
|
||||||
|
const variant = await productVariantServiceTx.create(
|
||||||
|
productId,
|
||||||
|
variantInput
|
||||||
|
)
|
||||||
|
|
||||||
|
return { variant }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeVariant(variant: ProductVariant) {
|
||||||
|
if (variant) {
|
||||||
|
await productVariantServiceTx.delete(variant.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInventoryItem(variant: ProductVariant) {
|
||||||
|
if (!variant.manage_inventory) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryItem = await inventoryServiceTx!.createInventoryItem({
|
||||||
|
sku: variant.sku,
|
||||||
|
origin_country: variant.origin_country,
|
||||||
|
hs_code: variant.hs_code,
|
||||||
|
mid_code: variant.mid_code,
|
||||||
|
material: variant.material,
|
||||||
|
weight: variant.weight,
|
||||||
|
length: variant.length,
|
||||||
|
height: variant.height,
|
||||||
|
width: variant.width,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { inventoryItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeInventoryItem(inventoryItem: InventoryItemDTO) {
|
||||||
|
if (inventoryItem) {
|
||||||
|
await inventoryServiceTx!.deleteInventoryItem(inventoryItem.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachInventoryItem(
|
||||||
|
variant: ProductVariant,
|
||||||
|
inventoryItem: InventoryItemDTO
|
||||||
|
) {
|
||||||
|
if (!variant.manage_inventory) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await productVariantInventoryServiceTx.attachInventoryItem(
|
||||||
|
variant.id,
|
||||||
|
inventoryItem.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transactionHandler(
|
||||||
|
actionId: string,
|
||||||
|
type: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
[actions.createVariant]: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async (
|
||||||
|
data: CreateProductVariantInput
|
||||||
|
) => {
|
||||||
|
return await createVariant(data)
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: async (
|
||||||
|
data: CreateProductVariantInput,
|
||||||
|
{ invoke }
|
||||||
|
) => {
|
||||||
|
await removeVariant(invoke[actions.createVariant])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[actions.createInventoryItem]: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async (
|
||||||
|
data: CreateProductVariantInput,
|
||||||
|
{ invoke }
|
||||||
|
) => {
|
||||||
|
const { [actions.createVariant]: variant } = invoke
|
||||||
|
|
||||||
|
return await createInventoryItem(variant)
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: async (
|
||||||
|
data: CreateProductVariantInput,
|
||||||
|
{ invoke }
|
||||||
|
) => {
|
||||||
|
await removeInventoryItem(invoke[actions.createInventoryItem])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[actions.attachInventoryItem]: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async (
|
||||||
|
data: CreateProductVariantInput,
|
||||||
|
{ invoke }
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
[actions.createVariant]: variant,
|
||||||
|
[actions.createInventoryItem]: inventoryItem,
|
||||||
|
} = invoke
|
||||||
|
|
||||||
|
return await attachInventoryItem(variant, inventoryItem)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return command[actionId][type](payload.data, payload.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = inventoryService
|
||||||
|
? createVariantStrategyWithInventory
|
||||||
|
: createSimpleVariantStrategy
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
ulid(),
|
||||||
|
transactionHandler,
|
||||||
|
input
|
||||||
|
)
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
|
if (transaction.getState() !== TransactionState.DONE) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
transaction
|
||||||
|
.getErrors()
|
||||||
|
.map((err) => err.error?.message)
|
||||||
|
.join("\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revertVariantTransaction = async (
|
||||||
|
dependencies: InjectedDependencies,
|
||||||
|
transaction: DistributedTransaction
|
||||||
|
) => {
|
||||||
|
const { inventoryService } = dependencies
|
||||||
|
const strategy = inventoryService
|
||||||
|
? createVariantStrategyWithInventory
|
||||||
|
: createSimpleVariantStrategy
|
||||||
|
|
||||||
|
await strategy.cancelTransaction(transaction)
|
||||||
|
}
|
||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator"
|
} from "class-validator"
|
||||||
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
|
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
|
||||||
import { PricingService, ProductService } from "../../../../services"
|
import {
|
||||||
|
PricingService,
|
||||||
|
ProductService,
|
||||||
|
ProductVariantInventoryService,
|
||||||
|
ProductVariantService,
|
||||||
|
} from "../../../../services"
|
||||||
import {
|
import {
|
||||||
ProductSalesChannelReq,
|
ProductSalesChannelReq,
|
||||||
ProductTagReq,
|
ProductTagReq,
|
||||||
@@ -23,10 +28,21 @@ import {
|
|||||||
import { Type } from "class-transformer"
|
import { Type } from "class-transformer"
|
||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
|
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
|
||||||
import { ProductStatus } from "../../../../models"
|
import { ProductStatus, ProductVariant } from "../../../../models"
|
||||||
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
|
import {
|
||||||
|
CreateProductVariantInput,
|
||||||
|
ProductVariantPricesUpdateReq,
|
||||||
|
} from "../../../../types/product-variant"
|
||||||
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
|
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
|
import { MedusaError } from "medusa-core-utils"
|
||||||
|
import { DistributedTransaction } from "../../../../utils/transaction"
|
||||||
|
import {
|
||||||
|
createVariantTransaction,
|
||||||
|
revertVariantTransaction,
|
||||||
|
} from "./transaction/create-product-variant"
|
||||||
|
import { IInventoryService } from "../../../../interfaces"
|
||||||
|
import { Logger } from "../../../../types/global"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [post] /products/{id}
|
* @oas [post] /products/{id}
|
||||||
@@ -96,14 +112,106 @@ export default async (req, res) => {
|
|||||||
|
|
||||||
const validated = await validator(AdminPostProductsProductReq, req.body)
|
const validated = await validator(AdminPostProductsProductReq, req.body)
|
||||||
|
|
||||||
|
const logger: Logger = req.scope.resolve("logger")
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
const productService: ProductService = req.scope.resolve("productService")
|
||||||
const pricingService: PricingService = req.scope.resolve("pricingService")
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
|
const productVariantService: ProductVariantService = req.scope.resolve(
|
||||||
|
"productVariantService"
|
||||||
|
)
|
||||||
|
const productVariantInventoryService: ProductVariantInventoryService =
|
||||||
|
req.scope.resolve("productVariantInventoryService")
|
||||||
|
const inventoryService: IInventoryService | undefined =
|
||||||
|
req.scope.resolve("inventoryService")
|
||||||
|
|
||||||
const manager: EntityManager = req.scope.resolve("manager")
|
const manager: EntityManager = req.scope.resolve("manager")
|
||||||
await manager.transaction(async (transactionManager) => {
|
await manager.transaction(async (transactionManager) => {
|
||||||
|
const { variants } = validated
|
||||||
|
delete validated.variants
|
||||||
|
|
||||||
await productService
|
await productService
|
||||||
.withTransaction(transactionManager)
|
.withTransaction(transactionManager)
|
||||||
.update(id, validated)
|
.update(id, validated)
|
||||||
|
|
||||||
|
if (!variants) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await productService
|
||||||
|
.withTransaction(transactionManager)
|
||||||
|
.retrieve(id, {
|
||||||
|
relations: ["variants"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Iterate product variants and update their properties accordingly
|
||||||
|
for (const variant of product.variants) {
|
||||||
|
const exists = variants.find((v) => v.id && variant.id === v.id)
|
||||||
|
if (!exists) {
|
||||||
|
await productVariantService
|
||||||
|
.withTransaction(transactionManager)
|
||||||
|
.delete(variant.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVariantTransactions: DistributedTransaction[] = []
|
||||||
|
const transactionDependencies = {
|
||||||
|
manager: transactionManager,
|
||||||
|
inventoryService,
|
||||||
|
productVariantInventoryService,
|
||||||
|
productVariantService,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, newVariant] of variants.entries()) {
|
||||||
|
const variantRank = index
|
||||||
|
|
||||||
|
if (newVariant.id) {
|
||||||
|
const variant = product.variants.find((v) => v.id === newVariant.id)
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_FOUND,
|
||||||
|
`Variant with id: ${newVariant.id} is not associated with this product`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await productVariantService
|
||||||
|
.withTransaction(transactionManager)
|
||||||
|
.update(variant, {
|
||||||
|
...newVariant,
|
||||||
|
variant_rank: variantRank,
|
||||||
|
product_id: variant.product_id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If the provided variant does not have an id, we assume that it
|
||||||
|
// should be created
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
...newVariant,
|
||||||
|
variant_rank: variantRank,
|
||||||
|
options: newVariant.options || [],
|
||||||
|
prices: newVariant.prices || [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const varTransation = await createVariantTransaction(
|
||||||
|
transactionDependencies,
|
||||||
|
product.id,
|
||||||
|
input as CreateProductVariantInput
|
||||||
|
)
|
||||||
|
allVariantTransactions.push(varTransation)
|
||||||
|
} catch (e) {
|
||||||
|
await Promise.all(
|
||||||
|
allVariantTransactions.map(async (transaction) => {
|
||||||
|
await revertVariantTransaction(
|
||||||
|
transactionDependencies,
|
||||||
|
transaction
|
||||||
|
).catch(() => logger.warn("Transaction couldn't be reverted."))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const rawProduct = await productService.retrieve(id, {
|
const rawProduct = await productService.retrieve(id, {
|
||||||
|
|||||||
@@ -60,10 +60,28 @@ export const products = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
multipleVariants: {
|
||||||
|
id: IdMap.getId("multipleVariants"),
|
||||||
|
title: "Multiple Variants",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: IdMap.getId("variant_1"),
|
||||||
|
title: "Variant 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IdMap.getId("variant_2"),
|
||||||
|
title: "Variant 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IdMap.getId("variant_3"),
|
||||||
|
title: "Variant 3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductServiceMock = {
|
export const ProductServiceMock = {
|
||||||
withTransaction: function() {
|
withTransaction: function () {
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
create: jest.fn().mockImplementation((data) => {
|
create: jest.fn().mockImplementation((data) => {
|
||||||
@@ -135,6 +153,9 @@ export const ProductServiceMock = {
|
|||||||
if (productId === IdMap.getId("variantsWithPrices")) {
|
if (productId === IdMap.getId("variantsWithPrices")) {
|
||||||
return Promise.resolve(products.variantsWithPrices)
|
return Promise.resolve(products.variantsWithPrices)
|
||||||
}
|
}
|
||||||
|
if (productId === IdMap.getId("multipleVariants")) {
|
||||||
|
return Promise.resolve(products.multipleVariants)
|
||||||
|
}
|
||||||
return Promise.resolve(undefined)
|
return Promise.resolve(undefined)
|
||||||
}),
|
}),
|
||||||
update: jest.fn().mockImplementation((product, data) => {
|
update: jest.fn().mockImplementation((product, data) => {
|
||||||
|
|||||||
@@ -247,20 +247,6 @@ describe("ProductService", () => {
|
|||||||
})
|
})
|
||||||
productTypeRepository.upsertType = mockUpsertType
|
productTypeRepository.upsertType = mockUpsertType
|
||||||
|
|
||||||
const productVariantRepository = MockRepository()
|
|
||||||
|
|
||||||
const productVariantService = {
|
|
||||||
withTransaction: function () {
|
|
||||||
return this
|
|
||||||
},
|
|
||||||
update: (variant, update) => {
|
|
||||||
if (variant.id) {
|
|
||||||
return update
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const productTagRepository = MockRepository({
|
const productTagRepository = MockRepository({
|
||||||
findOne: (data) => {
|
findOne: (data) => {
|
||||||
if (data.where.value === "test") {
|
if (data.where.value === "test") {
|
||||||
@@ -295,8 +281,6 @@ describe("ProductService", () => {
|
|||||||
const productService = new ProductService({
|
const productService = new ProductService({
|
||||||
manager: MockManager,
|
manager: MockManager,
|
||||||
productRepository,
|
productRepository,
|
||||||
productVariantService,
|
|
||||||
productVariantRepository,
|
|
||||||
productTagRepository,
|
productTagRepository,
|
||||||
productTypeRepository,
|
productTypeRepository,
|
||||||
eventBusService,
|
eventBusService,
|
||||||
@@ -327,17 +311,6 @@ describe("ProductService", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("successfully updates product variants", async () => {
|
|
||||||
await productService.update(IdMap.getId("ironman&co"), {
|
|
||||||
variants: [{ id: IdMap.getId("green"), title: "Greener" }],
|
|
||||||
})
|
|
||||||
|
|
||||||
// The update of variants will be tested in product variant test file
|
|
||||||
// Here we just test, that the function reaches its end when updating
|
|
||||||
// variants
|
|
||||||
expect(productRepository.save).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("successfully updates product status", async () => {
|
it("successfully updates product status", async () => {
|
||||||
await productService.update(IdMap.getId("ironman"), {
|
await productService.update(IdMap.getId("ironman"), {
|
||||||
status: "published",
|
status: "published",
|
||||||
@@ -376,30 +349,6 @@ describe("ProductService", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("successfully updates variant ranking", async () => {
|
|
||||||
await productService.update("ranking test", {
|
|
||||||
variants: [
|
|
||||||
{ id: "test_321", title: "Greener", variant_rank: 1 },
|
|
||||||
{ id: "test_123", title: "Blueer", variant_rank: 0 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
|
|
||||||
expect(eventBusService.emit).toHaveBeenCalledWith(
|
|
||||||
"product.updated",
|
|
||||||
expect.any(Object)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(productRepository.save).toHaveBeenCalledTimes(1)
|
|
||||||
expect(productRepository.save).toHaveBeenCalledWith({
|
|
||||||
id: "ranking test",
|
|
||||||
variants: [
|
|
||||||
{ id: "test_321", title: "Greener", variant_rank: 0 },
|
|
||||||
{ id: "test_123", title: "Blueer", variant_rank: 1 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("successfully updates tags", async () => {
|
it("successfully updates tags", async () => {
|
||||||
await productService.update(IdMap.getId("ironman"), {
|
await productService.update(IdMap.getId("ironman"), {
|
||||||
tags: [
|
tags: [
|
||||||
@@ -431,23 +380,6 @@ describe("ProductService", () => {
|
|||||||
expect(err.message).toEqual("Product with id: 123 was not found")
|
expect(err.message).toEqual("Product with id: 123 was not found")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws on wrong variant in update", async () => {
|
|
||||||
try {
|
|
||||||
await productService.update(IdMap.getId("ironman&co"), {
|
|
||||||
variants: [
|
|
||||||
{ id: IdMap.getId("yellow") },
|
|
||||||
{ id: IdMap.getId("green") },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
expect(err.message).toEqual(
|
|
||||||
`Variant with id: ${IdMap.getId(
|
|
||||||
"yellow"
|
|
||||||
)} is not associated with this product`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("delete", () => {
|
describe("delete", () => {
|
||||||
|
|||||||
@@ -249,13 +249,13 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
|||||||
* Attach a variant to an inventory item
|
* Attach a variant to an inventory item
|
||||||
* @param variantId variant id
|
* @param variantId variant id
|
||||||
* @param inventoryItemId inventory item id
|
* @param inventoryItemId inventory item id
|
||||||
* @param quantity quantity of variant to attach
|
* @param requiredQuantity quantity of variant to attach
|
||||||
* @returns the variant inventory item
|
* @returns the variant inventory item
|
||||||
*/
|
*/
|
||||||
async attachInventoryItem(
|
async attachInventoryItem(
|
||||||
variantId: string,
|
variantId: string,
|
||||||
inventoryItemId: string,
|
inventoryItemId: string,
|
||||||
quantity?: number
|
requiredQuantity?: number
|
||||||
): Promise<ProductVariantInventoryItem> {
|
): Promise<ProductVariantInventoryItem> {
|
||||||
const manager = this.transactionManager_ || this.manager_
|
const manager = this.transactionManager_ || this.manager_
|
||||||
|
|
||||||
@@ -289,14 +289,14 @@ class ProductVariantInventoryService extends TransactionBaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let quantityToStore = 1
|
let quantityToStore = 1
|
||||||
if (typeof quantity !== "undefined") {
|
if (typeof requiredQuantity !== "undefined") {
|
||||||
if (quantity < 1) {
|
if (requiredQuantity < 1) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
"Quantity must be greater than 0"
|
"Quantity must be greater than 0"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
quantityToStore = quantity
|
quantityToStore = requiredQuantity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ class ProductService extends TransactionBaseService {
|
|||||||
)
|
)
|
||||||
const imageRepo = manager.getCustomRepository(this.imageRepository_)
|
const imageRepo = manager.getCustomRepository(this.imageRepository_)
|
||||||
|
|
||||||
const relations = ["variants", "tags", "images"]
|
const relations = ["tags", "images"]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key)
|
this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key)
|
||||||
@@ -526,7 +526,6 @@ class ProductService extends TransactionBaseService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
variants,
|
|
||||||
metadata,
|
metadata,
|
||||||
images,
|
images,
|
||||||
tags,
|
tags,
|
||||||
@@ -581,57 +580,6 @@ class ProductService extends TransactionBaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variants) {
|
|
||||||
// Iterate product variants and update their properties accordingly
|
|
||||||
for (const variant of product.variants) {
|
|
||||||
const exists = variants.find((v) => v.id && variant.id === v.id)
|
|
||||||
if (!exists) {
|
|
||||||
await productVariantRepo.remove(variant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newVariants: ProductVariant[] = []
|
|
||||||
for (const [i, newVariant] of variants.entries()) {
|
|
||||||
const variant_rank = i
|
|
||||||
|
|
||||||
if (newVariant.id) {
|
|
||||||
const variant = product.variants.find((v) => v.id === newVariant.id)
|
|
||||||
|
|
||||||
if (!variant) {
|
|
||||||
throw new MedusaError(
|
|
||||||
MedusaError.Types.NOT_FOUND,
|
|
||||||
`Variant with id: ${newVariant.id} is not associated with this product`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await this.productVariantService_
|
|
||||||
.withTransaction(manager)
|
|
||||||
.update(variant, {
|
|
||||||
...newVariant,
|
|
||||||
variant_rank,
|
|
||||||
product_id: variant.product_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
newVariants.push(saved)
|
|
||||||
} else {
|
|
||||||
// If the provided variant does not have an id, we assume that it
|
|
||||||
// should be created
|
|
||||||
const created = await this.productVariantService_
|
|
||||||
.withTransaction(manager)
|
|
||||||
.create(product.id, {
|
|
||||||
...newVariant,
|
|
||||||
variant_rank,
|
|
||||||
options: newVariant.options || [],
|
|
||||||
prices: newVariant.prices || [],
|
|
||||||
})
|
|
||||||
|
|
||||||
newVariants.push(created)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
product.variants = newVariants
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(rest)) {
|
for (const [key, value] of Object.entries(rest)) {
|
||||||
if (isDefined(value)) {
|
if (isDefined(value)) {
|
||||||
product[key] = value
|
product[key] = value
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
} from "../../transaction"
|
} from "../../transaction"
|
||||||
|
|
||||||
describe("Transaction Orchestrator", () => {
|
describe("Transaction Orchestrator", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
it("Should follow the flow by calling steps in order with the correct payload", async () => {
|
it("Should follow the flow by calling steps in order with the correct payload", async () => {
|
||||||
const mocks = {
|
const mocks = {
|
||||||
one: jest.fn().mockImplementation((payload) => {
|
one: jest.fn().mockImplementation((payload) => {
|
||||||
@@ -187,7 +191,7 @@ describe("Transaction Orchestrator", () => {
|
|||||||
expect(actionOrder).toEqual(["one", "two", "three"])
|
expect(actionOrder).toEqual(["one", "two", "three"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Should forward step response if flag 'forwardResponse' is set to true", async () => {
|
it("Should store invoke's step response if flag 'saveResponse' is set to true", async () => {
|
||||||
const mocks = {
|
const mocks = {
|
||||||
one: jest.fn().mockImplementation((data) => {
|
one: jest.fn().mockImplementation((data) => {
|
||||||
return { abc: 1234 }
|
return { abc: 1234 }
|
||||||
@@ -195,8 +199,11 @@ describe("Transaction Orchestrator", () => {
|
|||||||
two: jest.fn().mockImplementation((data) => {
|
two: jest.fn().mockImplementation((data) => {
|
||||||
return { def: "567" }
|
return { def: "567" }
|
||||||
}),
|
}),
|
||||||
three: jest.fn().mockImplementation((data) => {
|
three: jest.fn().mockImplementation((data, context) => {
|
||||||
return { end: true }
|
return { end: true, onePropAbc: context.invoke.firstMethod.abc }
|
||||||
|
}),
|
||||||
|
four: jest.fn().mockImplementation((data) => {
|
||||||
|
return null
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,24 +224,36 @@ describe("Transaction Orchestrator", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
thirdMethod: {
|
thirdMethod: {
|
||||||
|
[TransactionHandlerType.INVOKE]: (data, context) => {
|
||||||
|
return mocks.three(data, context)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fourthMethod: {
|
||||||
[TransactionHandlerType.INVOKE]: (data) => {
|
[TransactionHandlerType.INVOKE]: (data) => {
|
||||||
return mocks.three(data)
|
return mocks.four(data)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return command[actionId][functionHandlerType]({ ...payload.data })
|
return command[actionId][functionHandlerType](
|
||||||
|
payload.data,
|
||||||
|
payload.context
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const flow: TransactionStepsDefinition = {
|
const flow: TransactionStepsDefinition = {
|
||||||
next: {
|
next: {
|
||||||
action: "firstMethod",
|
action: "firstMethod",
|
||||||
forwardResponse: true,
|
saveResponse: true,
|
||||||
next: {
|
next: {
|
||||||
action: "secondMethod",
|
action: "secondMethod",
|
||||||
forwardResponse: true,
|
saveResponse: true,
|
||||||
next: {
|
next: {
|
||||||
action: "thirdMethod",
|
action: "thirdMethod",
|
||||||
|
saveResponse: true,
|
||||||
|
next: {
|
||||||
|
action: "fourthMethod",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -245,18 +264,111 @@ describe("Transaction Orchestrator", () => {
|
|||||||
const transaction = await strategy.beginTransaction(
|
const transaction = await strategy.beginTransaction(
|
||||||
"transaction_id_123",
|
"transaction_id_123",
|
||||||
handler,
|
handler,
|
||||||
{
|
{ prop: 123 }
|
||||||
prop: 123,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await strategy.resume(transaction)
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
expect(mocks.one).toBeCalledWith({ prop: 123 })
|
expect(mocks.one).toBeCalledWith({ prop: 123 })
|
||||||
|
expect(mocks.two).toBeCalledWith({ prop: 123 })
|
||||||
|
|
||||||
expect(mocks.two).toBeCalledWith({ prop: 123, _response: { abc: 1234 } })
|
expect(mocks.three).toBeCalledWith(
|
||||||
|
{ prop: 123 },
|
||||||
|
{
|
||||||
|
invoke: {
|
||||||
|
firstMethod: { abc: 1234 },
|
||||||
|
secondMethod: { def: "567" },
|
||||||
|
thirdMethod: { end: true, onePropAbc: 1234 },
|
||||||
|
},
|
||||||
|
compensate: {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
expect(mocks.three).toBeCalledWith({ prop: 123, _response: { def: "567" } })
|
it("Should store compensate's step responses if flag 'saveResponse' is set to true", async () => {
|
||||||
|
const mocks = {
|
||||||
|
one: jest.fn().mockImplementation(() => {
|
||||||
|
return 1
|
||||||
|
}),
|
||||||
|
two: jest.fn().mockImplementation(() => {
|
||||||
|
return 2
|
||||||
|
}),
|
||||||
|
compensateOne: jest.fn().mockImplementation((compensateContext) => {
|
||||||
|
return "compensate 1 - 2 = " + compensateContext.secondMethod.two
|
||||||
|
}),
|
||||||
|
compensateTwo: jest.fn().mockImplementation((compensateContext) => {
|
||||||
|
return { two: "isCompensated" }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
actionId: string,
|
||||||
|
functionHandlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
firstMethod: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.one()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: ({ compensate }) => {
|
||||||
|
return mocks.compensateOne({ ...compensate })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secondMethod: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.two()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: ({ compensate }) => {
|
||||||
|
return mocks.compensateTwo({ ...compensate })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thirdMethod: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
throw new Error("failed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return command[actionId][functionHandlerType](payload.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: "firstMethod",
|
||||||
|
saveResponse: true,
|
||||||
|
next: {
|
||||||
|
action: "secondMethod",
|
||||||
|
saveResponse: true,
|
||||||
|
next: {
|
||||||
|
action: "thirdMethod",
|
||||||
|
noCompensation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
"transaction_id_123",
|
||||||
|
handler
|
||||||
|
)
|
||||||
|
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
const resposes = transaction.getContext()
|
||||||
|
|
||||||
|
expect(mocks.compensateTwo).toBeCalledWith({})
|
||||||
|
|
||||||
|
expect(mocks.compensateOne).toBeCalledWith({
|
||||||
|
secondMethod: {
|
||||||
|
two: "isCompensated",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resposes.compensate.firstMethod).toEqual(
|
||||||
|
"compensate 1 - 2 = isCompensated"
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Should continue the exection of next steps without waiting for the execution of all its parents when flag 'noWait' is set to true", async () => {
|
it("Should continue the exection of next steps without waiting for the execution of all its parents when flag 'noWait' is set to true", async () => {
|
||||||
@@ -362,8 +474,10 @@ describe("Transaction Orchestrator", () => {
|
|||||||
const flow: TransactionStepsDefinition = {
|
const flow: TransactionStepsDefinition = {
|
||||||
next: {
|
next: {
|
||||||
action: "firstMethod",
|
action: "firstMethod",
|
||||||
|
maxRetries: 3,
|
||||||
next: {
|
next: {
|
||||||
action: "secondMethod",
|
action: "secondMethod",
|
||||||
|
maxRetries: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -379,7 +493,7 @@ describe("Transaction Orchestrator", () => {
|
|||||||
|
|
||||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||||
expect(mocks.one).toBeCalledTimes(1)
|
expect(mocks.one).toBeCalledTimes(1)
|
||||||
expect(mocks.two).toBeCalledTimes(1 + strategy.DEFAULT_RETRIES)
|
expect(mocks.two).toBeCalledTimes(4)
|
||||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||||
|
|
||||||
@@ -428,6 +542,7 @@ describe("Transaction Orchestrator", () => {
|
|||||||
const flow: TransactionStepsDefinition = {
|
const flow: TransactionStepsDefinition = {
|
||||||
next: {
|
next: {
|
||||||
action: "firstMethod",
|
action: "firstMethod",
|
||||||
|
maxRetries: 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +555,7 @@ describe("Transaction Orchestrator", () => {
|
|||||||
|
|
||||||
await strategy.resume(transaction)
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
expect(mocks.one).toBeCalledTimes(1 + strategy.DEFAULT_RETRIES)
|
expect(mocks.one).toBeCalledTimes(2)
|
||||||
expect(transaction.getState()).toBe(TransactionState.FAILED)
|
expect(transaction.getState()).toBe(TransactionState.FAILED)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -666,4 +781,77 @@ describe("Transaction Orchestrator", () => {
|
|||||||
|
|
||||||
expect(resumedTransaction.getState()).toBe(TransactionState.REVERTED)
|
expect(resumedTransaction.getState()).toBe(TransactionState.REVERTED)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("Should revert a transaction when .cancelTransaction() is called", async () => {
|
||||||
|
const mocks = {
|
||||||
|
one: jest.fn().mockImplementation((payload) => {
|
||||||
|
return payload
|
||||||
|
}),
|
||||||
|
oneCompensate: jest.fn().mockImplementation((payload) => {
|
||||||
|
return payload
|
||||||
|
}),
|
||||||
|
two: jest.fn().mockImplementation((payload) => {
|
||||||
|
return payload
|
||||||
|
}),
|
||||||
|
twoCompensate: jest.fn().mockImplementation((payload) => {
|
||||||
|
return payload
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
actionId: string,
|
||||||
|
functionHandlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
firstMethod: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
mocks.one(payload)
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
mocks.oneCompensate(payload)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secondMethod: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
mocks.two(payload)
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
mocks.twoCompensate(payload)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return command[actionId][functionHandlerType](payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: "firstMethod",
|
||||||
|
next: {
|
||||||
|
action: "secondMethod",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
"transaction_id_123",
|
||||||
|
handler
|
||||||
|
)
|
||||||
|
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
|
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||||
|
expect(mocks.one).toBeCalledTimes(1)
|
||||||
|
expect(mocks.two).toBeCalledTimes(1)
|
||||||
|
|
||||||
|
await strategy.cancelTransaction(transaction)
|
||||||
|
|
||||||
|
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||||
|
expect(mocks.one).toBeCalledTimes(1)
|
||||||
|
expect(mocks.two).toBeCalledTimes(1)
|
||||||
|
expect(mocks.oneCompensate).toBeCalledTimes(1)
|
||||||
|
expect(mocks.twoCompensate).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import e from "express"
|
||||||
import { TransactionFlow, TransactionHandlerType, TransactionState } from "."
|
import { TransactionFlow, TransactionHandlerType, TransactionState } from "."
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} TransactionMetadata
|
* @typedef TransactionMetadata
|
||||||
* @property {string} producer - The id of the producer that created the transaction (transactionModelId).
|
* @property producer - The id of the producer that created the transaction (transactionModelId).
|
||||||
* @property {string} reply_to_topic - The topic to reply to for the transaction.
|
* @property reply_to_topic - The topic to reply to for the transaction.
|
||||||
* @property {string} idempotency_key - The idempotency key of the transaction.
|
* @property idempotency_key - The idempotency key of the transaction.
|
||||||
* @property {string} action - The action of the transaction.
|
* @property action - The action of the transaction.
|
||||||
* @property {TransactionHandlerType} action_type - The type of the transaction.
|
* @property action_type - The type of the transaction.
|
||||||
* @property {number} attempt - The number of attempts for the transaction.
|
* @property attempt - The number of attempts for the transaction.
|
||||||
* @property {number} timestamp - The timestamp of the transaction.
|
* @property timestamp - The timestamp of the transaction.
|
||||||
*/
|
*/
|
||||||
export type TransactionMetadata = {
|
export type TransactionMetadata = {
|
||||||
producer: string
|
producer: string
|
||||||
@@ -20,16 +21,44 @@ export type TransactionMetadata = {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef TransactionContext
|
||||||
|
* @property invoke - Object containing responses of Invoke handlers on steps flagged with saveResponse.
|
||||||
|
* @property compensate - Object containing responses of Compensate handlers on steps flagged with saveResponse.
|
||||||
|
*/
|
||||||
|
export class TransactionContext {
|
||||||
|
constructor(
|
||||||
|
public invoke: Record<string, unknown> = {},
|
||||||
|
public compensate: Record<string, unknown> = {}
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransactionStepError {
|
||||||
|
constructor(
|
||||||
|
public action: string,
|
||||||
|
public handlerType: TransactionHandlerType,
|
||||||
|
public error: Error | null
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransactionCheckpoint {
|
||||||
|
constructor(
|
||||||
|
public flow: TransactionFlow,
|
||||||
|
public context: TransactionContext,
|
||||||
|
public errors: TransactionStepError[] = []
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class TransactionPayload {
|
export class TransactionPayload {
|
||||||
/**
|
/**
|
||||||
* @param metadata - The metadata of the transaction.
|
* @param metadata - The metadata of the transaction.
|
||||||
* @param data - The payload data of the transaction and the response of the previous step if forwardResponse is true.
|
* @param data - The initial payload data to begin a transation.
|
||||||
|
* @param context - Object gathering responses of all steps flagged with saveResponse.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
public metadata: TransactionMetadata,
|
public metadata: TransactionMetadata,
|
||||||
public data: Record<string, unknown> & {
|
public data: Record<string, unknown>,
|
||||||
_response: Record<string, unknown>
|
public context: TransactionContext
|
||||||
}
|
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +69,10 @@ export class TransactionPayload {
|
|||||||
export class DistributedTransaction {
|
export class DistributedTransaction {
|
||||||
public modelId: string
|
public modelId: string
|
||||||
public transactionId: string
|
public transactionId: string
|
||||||
public errors: {
|
|
||||||
action: string
|
private errors: TransactionStepError[] = []
|
||||||
handlerType: TransactionHandlerType
|
|
||||||
error: Error | null
|
private context: TransactionContext = new TransactionContext()
|
||||||
}[] = []
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private flow: TransactionFlow,
|
private flow: TransactionFlow,
|
||||||
@@ -53,16 +81,34 @@ export class DistributedTransaction {
|
|||||||
handlerType: TransactionHandlerType,
|
handlerType: TransactionHandlerType,
|
||||||
payload: TransactionPayload
|
payload: TransactionPayload
|
||||||
) => Promise<unknown>,
|
) => Promise<unknown>,
|
||||||
public payload?: any
|
public payload?: any,
|
||||||
|
errors?: TransactionStepError[],
|
||||||
|
context?: TransactionContext
|
||||||
) {
|
) {
|
||||||
this.transactionId = flow.transactionId
|
this.transactionId = flow.transactionId
|
||||||
this.modelId = flow.transactionModelId
|
this.modelId = flow.transactionModelId
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
this.errors = errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
this.context = context
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFlow() {
|
public getFlow() {
|
||||||
return this.flow
|
return this.flow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getContext() {
|
||||||
|
return this.context
|
||||||
|
}
|
||||||
|
|
||||||
|
public getErrors() {
|
||||||
|
return this.errors
|
||||||
|
}
|
||||||
|
|
||||||
public addError(
|
public addError(
|
||||||
action: string,
|
action: string,
|
||||||
handlerType: TransactionHandlerType,
|
handlerType: TransactionHandlerType,
|
||||||
@@ -75,6 +121,14 @@ export class DistributedTransaction {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public addResponse(
|
||||||
|
action: string,
|
||||||
|
handlerType: TransactionHandlerType,
|
||||||
|
response: unknown
|
||||||
|
) {
|
||||||
|
this.context[handlerType][action] = response
|
||||||
|
}
|
||||||
|
|
||||||
public hasFinished(): boolean {
|
public hasFinished(): boolean {
|
||||||
return [
|
return [
|
||||||
TransactionState.DONE,
|
TransactionState.DONE,
|
||||||
@@ -106,15 +160,22 @@ export class DistributedTransaction {
|
|||||||
|
|
||||||
public static keyValueStore: any = {} // TODO: Use Key/Value db
|
public static keyValueStore: any = {} // TODO: Use Key/Value db
|
||||||
private static keyPrefix = "dtrans:"
|
private static keyPrefix = "dtrans:"
|
||||||
public async saveCheckpoint(): Promise<void> {
|
public async saveCheckpoint(): Promise<TransactionCheckpoint> {
|
||||||
// TODO: Use Key/Value db to save transactions
|
// TODO: Use Key/Value db to save transactions
|
||||||
const key = DistributedTransaction.keyPrefix + this.transactionId
|
const key = DistributedTransaction.keyPrefix + this.transactionId
|
||||||
DistributedTransaction.keyValueStore[key] = JSON.stringify(this.getFlow())
|
const data = new TransactionCheckpoint(
|
||||||
|
this.getFlow(),
|
||||||
|
this.getContext(),
|
||||||
|
this.getErrors()
|
||||||
|
)
|
||||||
|
DistributedTransaction.keyValueStore[key] = JSON.stringify(data)
|
||||||
|
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async loadTransactionFlow(
|
public static async loadTransaction(
|
||||||
transactionId: string
|
transactionId: string
|
||||||
): Promise<TransactionFlow | null> {
|
): Promise<TransactionCheckpoint | null> {
|
||||||
// TODO: Use Key/Value db to load transactions
|
// TODO: Use Key/Value db to load transactions
|
||||||
const key = DistributedTransaction.keyPrefix + transactionId
|
const key = DistributedTransaction.keyPrefix + transactionId
|
||||||
if (DistributedTransaction.keyValueStore[key]) {
|
if (DistributedTransaction.keyValueStore[key]) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type TransactionStepsDefinition = {
|
|||||||
timeout?: number
|
timeout?: number
|
||||||
async?: boolean
|
async?: boolean
|
||||||
noWait?: boolean
|
noWait?: boolean
|
||||||
forwardResponse?: boolean
|
saveResponse?: boolean
|
||||||
next?: TransactionStepsDefinition | TransactionStepsDefinition[]
|
next?: TransactionStepsDefinition | TransactionStepsDefinition[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
} from "."
|
} from "."
|
||||||
import {
|
import {
|
||||||
DistributedTransaction,
|
DistributedTransaction,
|
||||||
|
TransactionCheckpoint,
|
||||||
TransactionPayload,
|
TransactionPayload,
|
||||||
} from "./distributed-transaction"
|
} from "./distributed-transaction"
|
||||||
import { TransactionStep } from "./transaction-step"
|
import { TransactionStep, TransactionStepHandler } from "./transaction-step"
|
||||||
|
|
||||||
export type TransactionFlow = {
|
export type TransactionFlow = {
|
||||||
transactionModelId: string
|
transactionModelId: string
|
||||||
@@ -25,12 +26,6 @@ export type TransactionFlow = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransactionStepHandler = (
|
|
||||||
actionId: string,
|
|
||||||
handlerType: TransactionHandlerType,
|
|
||||||
payload: TransactionPayload
|
|
||||||
) => Promise<unknown>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @class TransactionOrchestrator is responsible for managing and executing distributed transactions.
|
* @class TransactionOrchestrator is responsible for managing and executing distributed transactions.
|
||||||
* It is based on a single transaction definition, which is used to execute all the transaction steps
|
* It is based on a single transaction definition, which is used to execute all the transaction steps
|
||||||
@@ -40,7 +35,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
private invokeSteps: string[] = []
|
private invokeSteps: string[] = []
|
||||||
private compensateSteps: string[] = []
|
private compensateSteps: string[] = []
|
||||||
|
|
||||||
public DEFAULT_RETRIES = 3
|
public DEFAULT_RETRIES = 0
|
||||||
constructor(
|
constructor(
|
||||||
public id: string,
|
public id: string,
|
||||||
private definition: TransactionStepsDefinition
|
private definition: TransactionStepsDefinition
|
||||||
@@ -218,6 +213,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
this.emit("finish", transaction)
|
this.emit("finish", transaction)
|
||||||
|
|
||||||
|
// TODO: check TransactionModel if it should delete the checkpoint when the transaction is done
|
||||||
void transaction.deleteCheckpoint()
|
void transaction.deleteCheckpoint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +249,14 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
response: unknown
|
response: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (step.forwardResponse) {
|
if (step.saveResponse) {
|
||||||
step.saveResponse(response)
|
transaction.addResponse(
|
||||||
|
step.definition.action!,
|
||||||
|
step.isCompensating()
|
||||||
|
? TransactionHandlerType.COMPENSATE
|
||||||
|
: TransactionHandlerType.INVOKE,
|
||||||
|
response
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
step.changeStatus(TransactionStepStatus.OK)
|
step.changeStatus(TransactionStepStatus.OK)
|
||||||
@@ -340,17 +342,6 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
step.changeStatus(TransactionStepStatus.WAITING)
|
step.changeStatus(TransactionStepStatus.WAITING)
|
||||||
|
|
||||||
const parent = this.getPreviousStep(flow, step)
|
|
||||||
let payloadData = transaction.payload
|
|
||||||
|
|
||||||
if (parent.forwardResponse) {
|
|
||||||
if (!payloadData) {
|
|
||||||
payloadData = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadData._response = parent.getResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = new TransactionPayload(
|
const payload = new TransactionPayload(
|
||||||
{
|
{
|
||||||
producer: flow.transactionModelId,
|
producer: flow.transactionModelId,
|
||||||
@@ -368,7 +359,8 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
attempt: step.attempts,
|
attempt: step.attempts,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
payloadData
|
transaction.payload,
|
||||||
|
transaction.getContext()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!step.definition.async) {
|
if (!step.definition.async) {
|
||||||
@@ -434,10 +426,33 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
await this.executeNext(transaction)
|
await this.executeNext(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel and revert a transaction compensating all its executed steps. It can be an ongoing transaction or a completed one
|
||||||
|
* @param transaction - The transaction to be reverted
|
||||||
|
*/
|
||||||
|
public async cancelTransaction(
|
||||||
|
transaction: DistributedTransaction
|
||||||
|
): Promise<void> {
|
||||||
|
if (transaction.modelId !== this.id) {
|
||||||
|
throw new Error(
|
||||||
|
`TransactionModel "${transaction.modelId}" cannot be orchestrated by "${this.id}" model.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = transaction.getFlow()
|
||||||
|
if (flow.state === TransactionState.FAILED) {
|
||||||
|
throw new Error(`Cannot revert a perment failed transaction.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
||||||
|
|
||||||
|
await this.executeNext(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
private async createTransactionFlow(
|
private async createTransactionFlow(
|
||||||
transactionId: string
|
transactionId: string
|
||||||
): Promise<TransactionFlow> {
|
): Promise<TransactionFlow> {
|
||||||
const model: TransactionFlow = {
|
return {
|
||||||
transactionModelId: this.id,
|
transactionModelId: this.id,
|
||||||
transactionId: transactionId,
|
transactionId: transactionId,
|
||||||
hasFailedSteps: false,
|
hasFailedSteps: false,
|
||||||
@@ -446,16 +461,19 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
definition: this.definition,
|
definition: this.definition,
|
||||||
steps: this.buildSteps(this.definition),
|
steps: this.buildSteps(this.definition),
|
||||||
}
|
}
|
||||||
return model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTransactionFlowById(
|
private async loadTransactionById(
|
||||||
transactionId: string
|
transactionId: string
|
||||||
): Promise<TransactionFlow | null> {
|
): Promise<TransactionCheckpoint | null> {
|
||||||
const flow = await DistributedTransaction.loadTransactionFlow(transactionId)
|
const transaction = await DistributedTransaction.loadTransaction(
|
||||||
if (flow !== null) {
|
transactionId
|
||||||
flow.steps = this.buildSteps(flow.definition, flow.steps)
|
)
|
||||||
return flow
|
|
||||||
|
if (transaction !== null) {
|
||||||
|
const flow = transaction.flow
|
||||||
|
transaction.flow.steps = this.buildSteps(flow.definition, flow.steps)
|
||||||
|
return transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -495,6 +513,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
level.push(obj.action)
|
level.push(obj.action)
|
||||||
const id = level.join(".")
|
const id = level.join(".")
|
||||||
const parent = level.slice(0, level.length - 1).join(".")
|
const parent = level.slice(0, level.length - 1).join(".")
|
||||||
|
|
||||||
states[parent].next?.push(id)
|
states[parent].next?.push(id)
|
||||||
|
|
||||||
const definitionCopy = { ...obj }
|
const definitionCopy = { ...obj }
|
||||||
@@ -506,7 +525,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
id,
|
id,
|
||||||
depth: level.length - 1,
|
depth: level.length - 1,
|
||||||
definition: definitionCopy,
|
definition: definitionCopy,
|
||||||
forwardResponse: definitionCopy.forwardResponse,
|
saveResponse: definitionCopy.saveResponse,
|
||||||
invoke: {
|
invoke: {
|
||||||
state: TransactionState.NOT_STARTED,
|
state: TransactionState.NOT_STARTED,
|
||||||
status: TransactionStepStatus.IDLE,
|
status: TransactionStepStatus.IDLE,
|
||||||
@@ -538,15 +557,24 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
handler: TransactionStepHandler,
|
handler: TransactionStepHandler,
|
||||||
payload?: unknown
|
payload?: unknown
|
||||||
): Promise<DistributedTransaction> {
|
): Promise<DistributedTransaction> {
|
||||||
let modelFlow = await this.getTransactionFlowById(transactionId)
|
const existingTransaction = await this.loadTransactionById(transactionId)
|
||||||
|
|
||||||
let newTransaction = false
|
let newTransaction = false
|
||||||
if (!modelFlow) {
|
let modelFlow
|
||||||
|
if (!existingTransaction) {
|
||||||
modelFlow = await this.createTransactionFlow(transactionId)
|
modelFlow = await this.createTransactionFlow(transactionId)
|
||||||
newTransaction = true
|
newTransaction = true
|
||||||
|
} else {
|
||||||
|
modelFlow = existingTransaction.flow
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction = new DistributedTransaction(modelFlow, handler, payload)
|
const transaction = new DistributedTransaction(
|
||||||
|
modelFlow,
|
||||||
|
handler,
|
||||||
|
payload,
|
||||||
|
existingTransaction?.errors,
|
||||||
|
existingTransaction?.context
|
||||||
|
)
|
||||||
if (newTransaction) {
|
if (newTransaction) {
|
||||||
await transaction.saveCheckpoint()
|
await transaction.saveCheckpoint()
|
||||||
}
|
}
|
||||||
@@ -583,18 +611,18 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
const existingTransaction = await this.getTransactionFlowById(
|
const existingTransaction = await this.loadTransactionById(transactionId)
|
||||||
transactionId
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existingTransaction === null) {
|
if (existingTransaction === null) {
|
||||||
throw new Error("Transaction could not be found.")
|
throw new Error(`Transaction ${transactionId} could not be found.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction = new DistributedTransaction(
|
transaction = new DistributedTransaction(
|
||||||
existingTransaction,
|
existingTransaction.flow,
|
||||||
handler!,
|
handler!,
|
||||||
payload
|
payload,
|
||||||
|
existingTransaction.errors,
|
||||||
|
existingTransaction.context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,24 +644,24 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
* @param responseIdempotencyKey - The idempotency key for the step
|
* @param responseIdempotencyKey - The idempotency key for the step
|
||||||
* @param handler - The handler function to execute the step
|
* @param handler - The handler function to execute the step
|
||||||
* @param transaction - The current transaction. If not provided it will be loaded based on the responseIdempotencyKey
|
* @param transaction - The current transaction. If not provided it will be loaded based on the responseIdempotencyKey
|
||||||
* @param payload - The payload of the step
|
* @param response - The response of the step
|
||||||
*/
|
*/
|
||||||
public async registerStepSuccess(
|
public async registerStepSuccess(
|
||||||
responseIdempotencyKey: string,
|
responseIdempotencyKey: string,
|
||||||
handler?: TransactionStepHandler,
|
handler?: TransactionStepHandler,
|
||||||
transaction?: DistributedTransaction,
|
transaction?: DistributedTransaction,
|
||||||
payload?: unknown
|
response?: unknown
|
||||||
): Promise<DistributedTransaction> {
|
): Promise<DistributedTransaction> {
|
||||||
const [curTransaction, step] =
|
const [curTransaction, step] =
|
||||||
await this.getTransactionAndStepFromIdempotencyKey(
|
await this.getTransactionAndStepFromIdempotencyKey(
|
||||||
responseIdempotencyKey,
|
responseIdempotencyKey,
|
||||||
handler,
|
handler,
|
||||||
transaction,
|
transaction,
|
||||||
payload
|
response
|
||||||
)
|
)
|
||||||
|
|
||||||
if (step.getStates().status === TransactionStepStatus.WAITING) {
|
if (step.getStates().status === TransactionStepStatus.WAITING) {
|
||||||
await this.setStepSuccess(curTransaction, step, payload)
|
await this.setStepSuccess(curTransaction, step, response)
|
||||||
this.emit("resume", curTransaction)
|
this.emit("resume", curTransaction)
|
||||||
await this.executeNext(curTransaction)
|
await this.executeNext(curTransaction)
|
||||||
} else {
|
} else {
|
||||||
@@ -651,21 +679,21 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
* @param error - The error that caused the failure
|
* @param error - The error that caused the failure
|
||||||
* @param handler - The handler function to execute the step
|
* @param handler - The handler function to execute the step
|
||||||
* @param transaction - The current transaction
|
* @param transaction - The current transaction
|
||||||
* @param payload - The payload of the step
|
* @param response - The response of the step
|
||||||
*/
|
*/
|
||||||
public async registerStepFailure(
|
public async registerStepFailure(
|
||||||
responseIdempotencyKey: string,
|
responseIdempotencyKey: string,
|
||||||
error: Error | null,
|
error: Error | null,
|
||||||
handler?: TransactionStepHandler,
|
handler?: TransactionStepHandler,
|
||||||
transaction?: DistributedTransaction,
|
transaction?: DistributedTransaction,
|
||||||
payload?: unknown
|
response?: unknown
|
||||||
): Promise<DistributedTransaction> {
|
): Promise<DistributedTransaction> {
|
||||||
const [curTransaction, step] =
|
const [curTransaction, step] =
|
||||||
await this.getTransactionAndStepFromIdempotencyKey(
|
await this.getTransactionAndStepFromIdempotencyKey(
|
||||||
responseIdempotencyKey,
|
responseIdempotencyKey,
|
||||||
handler,
|
handler,
|
||||||
transaction,
|
transaction,
|
||||||
payload
|
response
|
||||||
)
|
)
|
||||||
|
|
||||||
if (step.getStates().status === TransactionStepStatus.WAITING) {
|
if (step.getStates().status === TransactionStepStatus.WAITING) {
|
||||||
@@ -680,8 +708,4 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
return curTransaction
|
return curTransaction
|
||||||
}
|
}
|
||||||
|
|
||||||
public cancelTransaction(transactionId: string) {
|
|
||||||
// TODO: stop a transaction while in progress and compensate all executed steps
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import {
|
|||||||
TransactionStepsDefinition,
|
TransactionStepsDefinition,
|
||||||
TransactionStepStatus,
|
TransactionStepStatus,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
|
TransactionHandlerType,
|
||||||
|
TransactionPayload,
|
||||||
} from "."
|
} from "."
|
||||||
|
|
||||||
|
export type TransactionStepHandler = (
|
||||||
|
actionId: string,
|
||||||
|
handlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) => Promise<unknown>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @class TransactionStep
|
* @class TransactionStep
|
||||||
* @classdesc A class representing a single step in a transaction flow
|
* @classdesc A class representing a single step in a transaction flow
|
||||||
@@ -19,8 +27,7 @@ export class TransactionStep {
|
|||||||
* @member failures - The number of failures encountered while executing the step
|
* @member failures - The number of failures encountered while executing the step
|
||||||
* @member lastAttempt - The timestamp of the last attempt made to execute the step
|
* @member lastAttempt - The timestamp of the last attempt made to execute the step
|
||||||
* @member next - The ids of the next steps in the flow
|
* @member next - The ids of the next steps in the flow
|
||||||
* @member response - The response from the last successful execution of the step
|
* @member saveResponse - A flag indicating if the response of a step should be shared in the transaction context and available to subsequent steps - default is false
|
||||||
* @member forwardResponse - A flag indicating if the response from the previous step should be passed to this step as payload
|
|
||||||
*/
|
*/
|
||||||
private stepFailed = false
|
private stepFailed = false
|
||||||
id: string
|
id: string
|
||||||
@@ -38,8 +45,7 @@ export class TransactionStep {
|
|||||||
failures: number
|
failures: number
|
||||||
lastAttempt: number | null
|
lastAttempt: number | null
|
||||||
next: string[]
|
next: string[]
|
||||||
response: unknown
|
saveResponse: boolean
|
||||||
forwardResponse: boolean
|
|
||||||
|
|
||||||
public getStates() {
|
public getStates() {
|
||||||
return this.isCompensating() ? this.compensate : this.invoke
|
return this.isCompensating() ? this.compensate : this.invoke
|
||||||
@@ -123,14 +129,6 @@ export class TransactionStep {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveResponse(response) {
|
|
||||||
this.response = response
|
|
||||||
}
|
|
||||||
|
|
||||||
public getResponse(): unknown {
|
|
||||||
return this.response
|
|
||||||
}
|
|
||||||
|
|
||||||
canRetry(): boolean {
|
canRetry(): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
this.lastAttempt &&
|
this.lastAttempt &&
|
||||||
|
|||||||
Reference in New Issue
Block a user