chore(orchestration): idempotent (#7771)
This commit is contained in:
@@ -498,10 +498,10 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(4)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(4)
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
expect(mocks.compensateOne).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(mocks.two).nthCalledWith(
|
||||
1,
|
||||
@@ -561,7 +561,7 @@ describe("Transaction Orchestrator", () => {
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(2)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(2)
|
||||
expect(transaction.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
@@ -617,8 +617,8 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(2)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(2)
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
expect(transaction.isPartiallyCompleted).toBe(true)
|
||||
})
|
||||
@@ -677,8 +677,8 @@ describe("Transaction Orchestrator", () => {
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(0)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(0)
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
expect(transaction.getFlow().hasWaitingSteps).toBe(true)
|
||||
|
||||
@@ -771,9 +771,9 @@ describe("Transaction Orchestrator", () => {
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(0)
|
||||
expect(mocks.two).toBeCalledTimes(0)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.compensateOne).toHaveBeenCalledTimes(0)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(0)
|
||||
|
||||
const registerBeforeAllowed = await strategy
|
||||
.registerStepSuccess(mockSecondStepId, handler)
|
||||
@@ -791,7 +791,7 @@ describe("Transaction Orchestrator", () => {
|
||||
)
|
||||
|
||||
expect(resumedTransaction.getState()).toBe(TransactionState.COMPENSATING)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
expect(mocks.compensateOne).toHaveBeenCalledTimes(1)
|
||||
|
||||
const mocktransactionIdCompensate = TransactionOrchestrator.getKeyName(
|
||||
"transaction-name",
|
||||
@@ -881,11 +881,11 @@ describe("Transaction Orchestrator", () => {
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(1)
|
||||
expect(mocks.compensateTwo).toBeCalledTimes(1)
|
||||
expect(mocks.three).toBeCalledTimes(1)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.compensateOne).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.compensateTwo).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.three).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
@@ -951,16 +951,16 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(1)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(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)
|
||||
expect(mocks.one).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.two).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.oneCompensate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.twoCompensate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("Should receive the current transaction as reference in the handler", async () => {
|
||||
@@ -1081,10 +1081,10 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.f1).toBeCalledTimes(2)
|
||||
expect(mocks.f2).toBeCalledTimes(2)
|
||||
expect(mocks.f3).toBeCalledTimes(2)
|
||||
expect(mocks.f4).toBeCalledTimes(0)
|
||||
expect(mocks.f1).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f2).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f3).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f4).toHaveBeenCalledTimes(0)
|
||||
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||
@@ -1187,10 +1187,10 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.f1).toBeCalledTimes(1)
|
||||
expect(mocks.f2).toBeCalledTimes(1)
|
||||
expect(mocks.f3).toBeCalledTimes(1)
|
||||
expect(mocks.f4).toBeCalledTimes(0)
|
||||
expect(mocks.f1).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.f2).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.f3).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.f4).toHaveBeenCalledTimes(0)
|
||||
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||
@@ -1304,10 +1304,10 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.f1).toBeCalledTimes(2)
|
||||
expect(mocks.f2).toBeCalledTimes(2)
|
||||
expect(mocks.f3).toBeCalledTimes(2)
|
||||
expect(mocks.f4).toBeCalledTimes(0)
|
||||
expect(mocks.f1).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f2).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f3).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f4).toHaveBeenCalledTimes(0)
|
||||
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||
@@ -1413,10 +1413,10 @@ describe("Transaction Orchestrator", () => {
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(mocks.f1).toBeCalledTimes(2)
|
||||
expect(mocks.f2).toBeCalledTimes(2)
|
||||
expect(mocks.f3).toBeCalledTimes(2)
|
||||
expect(mocks.f4).toBeCalledTimes(0)
|
||||
expect(mocks.f1).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f2).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f3).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.f4).toHaveBeenCalledTimes(0)
|
||||
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
TransactionCheckpoint,
|
||||
} from "../distributed-transaction"
|
||||
import { TransactionStep } from "../transaction-step"
|
||||
import { SchedulerOptions } from "../types"
|
||||
import { SchedulerOptions, TransactionOptions } from "../types"
|
||||
|
||||
export interface IDistributedSchedulerStorage {
|
||||
schedule(
|
||||
@@ -17,9 +17,17 @@ export interface IDistributedSchedulerStorage {
|
||||
}
|
||||
|
||||
export interface IDistributedTransactionStorage {
|
||||
get(key: string): Promise<TransactionCheckpoint | undefined>
|
||||
get(
|
||||
key: string,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionCheckpoint | undefined>
|
||||
list(): Promise<TransactionCheckpoint[]>
|
||||
save(key: string, data: TransactionCheckpoint, ttl?: number): Promise<void>
|
||||
save(
|
||||
key: string,
|
||||
data: TransactionCheckpoint,
|
||||
ttl?: number,
|
||||
options?: TransactionOptions
|
||||
): Promise<void>
|
||||
scheduleRetry(
|
||||
transaction: DistributedTransaction,
|
||||
step: TransactionStep,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TransactionState } from "@medusajs/utils"
|
||||
import { TransactionCheckpoint } from "../distributed-transaction"
|
||||
import { TransactionOptions } from "../types"
|
||||
import { DistributedTransactionStorage } from "./abstract-storage"
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
@@ -11,7 +12,10 @@ export class BaseInMemoryDistributedTransactionStorage extends DistributedTransa
|
||||
this.storage = new Map()
|
||||
}
|
||||
|
||||
async get(key: string): Promise<TransactionCheckpoint | undefined> {
|
||||
async get(
|
||||
key: string,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionCheckpoint | undefined> {
|
||||
return this.storage.get(key)
|
||||
}
|
||||
|
||||
@@ -22,7 +26,8 @@ export class BaseInMemoryDistributedTransactionStorage extends DistributedTransa
|
||||
async save(
|
||||
key: string,
|
||||
data: TransactionCheckpoint,
|
||||
ttl?: number
|
||||
ttl?: number,
|
||||
options?: TransactionOptions
|
||||
): Promise<void> {
|
||||
const hasFinished = [
|
||||
TransactionState.DONE,
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
TransactionOrchestrator,
|
||||
} from "./transaction-orchestrator"
|
||||
import { TransactionStep, TransactionStepHandler } from "./transaction-step"
|
||||
import {
|
||||
SchedulerOptions,
|
||||
TransactionHandlerType,
|
||||
TransactionState,
|
||||
} from "./types"
|
||||
import { TransactionHandlerType, TransactionState } from "./types"
|
||||
|
||||
/**
|
||||
* @typedef TransactionMetadata
|
||||
@@ -90,7 +86,7 @@ export class DistributedTransaction extends EventEmitter {
|
||||
this.keyValueStore = storage
|
||||
}
|
||||
|
||||
public static keyPrefix = "dtrans"
|
||||
public static keyPrefix = "dtrx"
|
||||
|
||||
constructor(
|
||||
private flow: TransactionFlow,
|
||||
@@ -191,7 +187,10 @@ export class DistributedTransaction extends EventEmitter {
|
||||
public async saveCheckpoint(
|
||||
ttl = 0
|
||||
): Promise<TransactionCheckpoint | undefined> {
|
||||
const options = this.getFlow().options
|
||||
const options =
|
||||
TransactionOrchestrator.getWorkflowOptions(this.modelId) ??
|
||||
this.getFlow().options
|
||||
|
||||
if (!options?.store) {
|
||||
return
|
||||
}
|
||||
@@ -207,7 +206,7 @@ export class DistributedTransaction extends EventEmitter {
|
||||
this.modelId,
|
||||
this.transactionId
|
||||
)
|
||||
await DistributedTransaction.keyValueStore.save(key, data, ttl)
|
||||
await DistributedTransaction.keyValueStore.save(key, data, ttl, options)
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -222,7 +221,11 @@ export class DistributedTransaction extends EventEmitter {
|
||||
transactionId
|
||||
)
|
||||
|
||||
const loadedData = await DistributedTransaction.keyValueStore.get(key)
|
||||
const options = TransactionOrchestrator.getWorkflowOptions(modelId)
|
||||
const loadedData = await DistributedTransaction.keyValueStore.get(
|
||||
key,
|
||||
options
|
||||
)
|
||||
if (loadedData) {
|
||||
return loadedData
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
import { TransactionStep, TransactionStepHandler } from "./transaction-step"
|
||||
import {
|
||||
DistributedTransactionEvent,
|
||||
StepFeatures,
|
||||
TransactionHandlerType,
|
||||
TransactionModelOptions,
|
||||
TransactionOptions,
|
||||
TransactionState,
|
||||
TransactionStepsDefinition,
|
||||
TransactionStepStatus,
|
||||
@@ -56,12 +58,22 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
private compensateSteps: string[] = []
|
||||
|
||||
public static DEFAULT_RETRIES = 0
|
||||
|
||||
private static workflowOptions: {
|
||||
[modelId: string]: TransactionOptions
|
||||
} = {}
|
||||
|
||||
public static getWorkflowOptions(modelId: string): TransactionOptions {
|
||||
return this.workflowOptions[modelId]
|
||||
}
|
||||
|
||||
constructor(
|
||||
public id: string,
|
||||
private definition: TransactionStepsDefinition,
|
||||
private options?: TransactionModelOptions
|
||||
) {
|
||||
super()
|
||||
this.parseFlowOptions()
|
||||
}
|
||||
|
||||
private static SEPARATOR = ":"
|
||||
@@ -409,6 +421,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
}
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
const options = TransactionOrchestrator.getWorkflowOptions(flow.modelId)
|
||||
|
||||
if (!hasStepTimedOut) {
|
||||
step.changeStatus(TransactionStepStatus.OK)
|
||||
@@ -420,7 +433,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
step.changeState(TransactionStepState.DONE)
|
||||
}
|
||||
|
||||
if (step.definition.async || flow.options?.storeExecution) {
|
||||
if (step.definition.async || options?.storeExecution) {
|
||||
await transaction.saveCheckpoint()
|
||||
}
|
||||
|
||||
@@ -497,6 +510,8 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
}
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
const options = TransactionOrchestrator.getWorkflowOptions(flow.modelId)
|
||||
|
||||
const cleaningUp: Promise<unknown>[] = []
|
||||
|
||||
const hasTimedOut = step.getStates().state === TransactionStepState.TIMEOUT
|
||||
@@ -536,7 +551,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (step.definition.async || flow.options?.storeExecution) {
|
||||
if (step.definition.async || options?.storeExecution) {
|
||||
await transaction.saveCheckpoint()
|
||||
}
|
||||
|
||||
@@ -563,6 +578,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
}
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
const options = TransactionOrchestrator.getWorkflowOptions(flow.modelId)
|
||||
const nextSteps = await this.checkAllSteps(transaction)
|
||||
const execution: Promise<void | unknown>[] = []
|
||||
|
||||
@@ -764,7 +780,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSyncSteps && flow.options?.storeExecution) {
|
||||
if (hasSyncSteps && options?.storeExecution) {
|
||||
await transaction.saveCheckpoint()
|
||||
}
|
||||
|
||||
@@ -798,7 +814,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
flow.state = TransactionState.INVOKING
|
||||
flow.startedAt = Date.now()
|
||||
|
||||
if (this.options?.store) {
|
||||
if (this.getOptions().store) {
|
||||
await transaction.saveCheckpoint(
|
||||
flow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
|
||||
)
|
||||
@@ -843,7 +859,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
await this.executeNext(transaction)
|
||||
}
|
||||
|
||||
private createTransactionFlow(transactionId: string, flowMetadata?: TransactionFlow['metadata']): TransactionFlow {
|
||||
private parseFlowOptions() {
|
||||
const [steps, features] = TransactionOrchestrator.buildSteps(
|
||||
this.definition
|
||||
)
|
||||
@@ -854,22 +870,47 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
const hasStepTimeouts = features.hasStepTimeouts
|
||||
const hasRetriesTimeout = features.hasRetriesTimeout
|
||||
const hasTransactionTimeout = !!this.options.timeout
|
||||
const isIdempotent = !!this.options.idempotent
|
||||
|
||||
if (hasAsyncSteps) {
|
||||
this.options.store = true
|
||||
}
|
||||
|
||||
if (hasStepTimeouts || hasRetriesTimeout || hasTransactionTimeout) {
|
||||
if (
|
||||
hasStepTimeouts ||
|
||||
hasRetriesTimeout ||
|
||||
hasTransactionTimeout ||
|
||||
isIdempotent
|
||||
) {
|
||||
this.options.store = true
|
||||
this.options.storeExecution = true
|
||||
}
|
||||
|
||||
const parsedOptions = {
|
||||
...this.options,
|
||||
hasAsyncSteps,
|
||||
hasStepTimeouts,
|
||||
hasRetriesTimeout,
|
||||
}
|
||||
TransactionOrchestrator.workflowOptions[this.id] = parsedOptions
|
||||
|
||||
return [steps, features]
|
||||
}
|
||||
|
||||
private createTransactionFlow(
|
||||
transactionId: string,
|
||||
flowMetadata?: TransactionFlow["metadata"]
|
||||
): TransactionFlow {
|
||||
const [steps, features] = TransactionOrchestrator.buildSteps(
|
||||
this.definition
|
||||
)
|
||||
|
||||
const flow: TransactionFlow = {
|
||||
modelId: this.id,
|
||||
options: this.options,
|
||||
transactionId: transactionId,
|
||||
metadata: flowMetadata,
|
||||
hasAsyncSteps,
|
||||
hasAsyncSteps: features.hasAsyncSteps,
|
||||
hasFailedSteps: false,
|
||||
hasSkippedSteps: false,
|
||||
hasWaitingSteps: false,
|
||||
@@ -909,14 +950,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
private static buildSteps(
|
||||
flow: TransactionStepsDefinition,
|
||||
existingSteps?: { [key: string]: TransactionStep }
|
||||
): [
|
||||
{ [key: string]: TransactionStep },
|
||||
{
|
||||
hasAsyncSteps: boolean
|
||||
hasStepTimeouts: boolean
|
||||
hasRetriesTimeout: boolean
|
||||
}
|
||||
] {
|
||||
): [{ [key: string]: TransactionStep }, StepFeatures] {
|
||||
const states: { [key: string]: TransactionStep } = {
|
||||
[TransactionOrchestrator.ROOT_STEP]: {
|
||||
id: TransactionOrchestrator.ROOT_STEP,
|
||||
@@ -938,12 +972,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
while (queue.length > 0) {
|
||||
const { obj, level } = queue.shift()
|
||||
|
||||
for (const key in obj) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
queue.push({ obj: obj[key], level: [...level] })
|
||||
} else if (key === "action") {
|
||||
@@ -1039,7 +1068,11 @@ export class TransactionOrchestrator extends EventEmitter {
|
||||
existingTransaction?.context
|
||||
)
|
||||
|
||||
if (newTransaction && this.options?.store && this.options?.storeExecution) {
|
||||
if (
|
||||
newTransaction &&
|
||||
this.getOptions().store &&
|
||||
this.getOptions().storeExecution
|
||||
) {
|
||||
await transaction.saveCheckpoint(
|
||||
modelFlow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
|
||||
)
|
||||
|
||||
@@ -118,6 +118,11 @@ export type TransactionModelOptions = {
|
||||
*/
|
||||
storeExecution?: boolean
|
||||
|
||||
/**
|
||||
* If true, the workflow will use the transaction ID as the key to ensure only-once execution
|
||||
*/
|
||||
idempotent?: boolean
|
||||
|
||||
/**
|
||||
* Defines the workflow as a scheduled workflow that executes based on the cron configuration passed.
|
||||
* The value can either by a cron expression string, or an object that also allows to define the concurrency behavior.
|
||||
@@ -207,3 +212,11 @@ export type DistributedTransactionEvents = {
|
||||
transaction: DistributedTransaction
|
||||
}) => void
|
||||
}
|
||||
|
||||
export type StepFeatures = {
|
||||
hasAsyncSteps: boolean
|
||||
hasStepTimeouts: boolean
|
||||
hasRetriesTimeout: boolean
|
||||
}
|
||||
|
||||
export type TransactionOptions = TransactionModelOptions & StepFeatures
|
||||
|
||||
Reference in New Issue
Block a user