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
- Invoke the debit/credit operations through the public workflow or service method that opens a transaction internally
- Pass a context containing transactionManager (e.g. via @InjectTransactionManager or within a createStep transaction)
- 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
- Always use withTransaction for debit/credit
- Never call internal locking methods outside workflows
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.