medusajs/medusa · critical · MedusaError

unexpected_state

unexpected_state

Error message

Cannot lock store credit accounts outside of a transaction

What it means

lockAccountsForUpdate_ throws UNEXPECTED_STATE when it is invoked without a transactional context. Locking store credit accounts via SELECT ... FOR UPDATE only prevents double-spends if the lock and the subsequent writes share one transaction; a non-transactional connection would release the lock immediately, so the code fails loudly instead of silently reintroducing the race.

Source

Thrown at packages/plugins/loyalty/src/modules/store-credit/service.ts:354

    accountIds: string[],
    @MedusaContext() sharedContext: Context = {}
  ) {
    const uniqueAccountIds = Array.from(new Set(accountIds)).sort();

    if (!uniqueAccountIds.length) {
      return;
    }

    const manager = sharedContext.transactionManager as SqlEntityManager;
    const transactionContext = manager.getTransactionContext();

    if (!transactionContext) {
      /*
        Falling back to a non-transactional connection would release the lock
        immediately and silently reintroduce the double-spend race, so we fail
        loudly instead.
      */
      throw new MedusaError(
        MedusaError.Types.UNEXPECTED_STATE,
        "Cannot lock store credit accounts outside of a transaction"
      );
    }

    await transactionContext("store_credit_account")
      .select("id")
      .whereIn("id", uniqueAccountIds)
      .forUpdate();
  }

  @InjectTransactionManager()
  async debitAccounts_(
    debitAccountsData: ModuleDebitAccount[],
    @MedusaContext() sharedContext: Context = {}
  ) {
    const manager = sharedContext.transactionManager as SqlEntityManager;
    const transactions: ModuleAccountTransaction[] = [];

View on GitHub (pinned to 5e06e544a2)

Solutions

  1. Invoke the debit/credit operations through the public workflow or service method that opens a transaction internally
  2. Pass a context containing transactionManager (e.g. via @InjectTransactionManager or within a createStep transaction)
  3. If calling directly, wrap the call in service.withTransaction(...) so the context carries a transaction

Example fix

// before
await storeCreditService.debitAccounts_(entries, {})
// after
await storeCreditService.withTransaction(async (tx) => {
  await tx.debitAccounts_(entries)
})
Defensive patterns

Strategy: try-catch

Validate before calling

if (!context.transactionManager) {
  throw new Error('Open a transaction before debiting store credit')
}

Try / catch

try {
  await service.withTransaction((tx) => tx.debitAccounts_(entries))
} catch (e) {
  if (e instanceof MedusaError && e.message.includes('outside of a transaction')) {
    // re-run inside a transaction
  }
}

Prevention

When it happens

Trigger: Calling debit/credit flows (which call lockAccountsForUpdate_) without wrapping them in a module transaction, e.g. calling debitAccounts_ directly with a sharedContext that has no transactionManager, or a custom step that bypasses the transactional workflow path.

Common situations: Calling module methods from a workflow step that skips the distributed transaction, passing a plain context object instead of one created by the module's transaction manager, or upgrading the plugin and using an internal method externally.

Related errors


AI-assisted analysis of medusajs/medusa@5e06e544a2 (2026-08-27). Data as JSON: /api/errors/f816d675faee5bbf. Report an issue: GitHub.