🕰️ Chapters
- Demoing Why You'd Use A Transaction
- What Is A Database Transaction?
- Creating A Database Transaction
- Binding Our Transaction To Our Database Operations
- Demoing A Transaction In Action
- Displaying An Error On Transaction Rollback
Let's Learn AdonisJS 6 #10.2
Saving All Or Nothing with Database Transactions
We'll learn about database transactions and how we can use them to batch commit or rollback updates, safeguarding against partial updates due to errors.
- Created by
- @tomgobich
- Published
Join the Discussion 4 comments
-
Hi @tomgobich
I’m having trouble handling transactions after my refactor. How would you approach this?type Params = { payload: Infer<typeof forgotPasswordValidator> } type SendPasswordResetResult = { status: 'sent' } | { status: 'rate_limited' } @inject() export default class SendPasswordResetEmail { constructor(protected rateLimiterService: RateLimiterService) {} async handle({ payload }: Params): Promise<SendPasswordResetResult> { const ipLimiter = this.rateLimiterService.forgotPasswordIpLimiter() const emailLimiter = this.rateLimiterService.forgotPasswordEmailLimiter() const ipKey = this.rateLimiterService.forgotPasswordIpKey() const emailKey = this.rateLimiterService.forgotPasswordEmailKey(payload.email) try { await ipLimiter.consume(ipKey) await emailLimiter.consume(emailKey) const user = await User.query().where('email', payload.email).first() const { token, hash } = generateTokenAndHash() if (!user) return { status: 'sent' } // Transaction is needed here await RevokePasswordResetTokens.handle({ user }) await CreatePasswordResetToken.handle({ user, hash }) PasswordResetRequested.dispatch(user, token) return { status: 'sent' } } catch (error) { if (error instanceof errors.E_TOO_MANY_REQUESTS) { return { status: 'rate_limited' } } throw error } } }Copied!// This class revokes all active password‑reset tokens for the user export default class RevokePasswordResetTokens { static async handle({ user }: Params) { await validPasswordResetTokens(user).update({ revokedAt: DateTime.now(), revokedBy: 'user', revokedReason: 'new_token_requested', }) } }Copied!// This query returns all valid password‑reset tokens for the user, export function validPasswordResetTokens(user: User) { return user .related('passwordResetTokens') .query() .whereNull('usedAt') .whereNull('revokedAt') .where('expiresAt', '>=', DateTime.now().toSQL()) }Copied!// This class creates a new password‑reset tokens record, // storing a hash that expires in 15 minutes. export default class CreatePasswordResetToken { static async handle({ user, hash }: Params) { await user.related('passwordResetTokens').create({ hash, expiresAt: DateTime.now().plus({ minutes: 15 }), }) } }Copied!I suppose I have to start it like this, but after that I’m stuck:
await db.transaction(async (trx) => { await RevokePasswordResetTokens.handle({ user, trx }) await CreatePasswordResetToken.handle({ user, hash, trx }) })Copied!1-
Responding to gregory
Hi @gregory! I've got good news, transactions cascade through relationships. It seems like everything here is stemming from your user, so you should just need to attach it to your user and Lucid will do the rest!
await db.transaction(async (trx) => { user.useTransaction(trx) await RevokePasswordResetTokens.handle({ user }) await CreatePasswordResetToken.handle({ user, hash }) })Copied!It looks like you're good here with what you have, but just as an additional note of something to be mindful of. Managed transactions rely on the exception being thrown to know whether something failed. So when using transactions across methods/actions like this be sure to be mindful about
try/catchstatements you may add down the road.0-
Responding to tomgobich
Thanks! I ended up doing this before reading your post. I’m not sure which approach is better from an experienced developer’s point of view, but they both work.
I did read the documentation, but since I’m not a native English speaker, it didn’t occur to me that transactions cascade through relationships was the solution. Now it’s crystal clear. I feel a bit silly for missing it.
PS: Thank you for always replying to my questions.const result = await db.transaction(async (trx) => { const user = await User.query({ client: trx }).where('email', payload.email).first() const { token, hash } = generateTokenAndHash() if (!user) return null await RevokePasswordResetTokens.handle({ user }) await CreatePasswordResetToken.handle({ user, hash }) return { user, token } }) if (result) { PasswordResetRequested.dispatch(result.user, result.token) } return { status: 'sent' }Copied!1-
Responding to gregory
Anytime!! The solution you arrived at looks great! Since you're within an action, you could pluck that section out into a private method to separate concerns a bit and give that section of code a name. Completely optional though, either way works just fine!
@inject() export default class SendPasswordResetEmail { constructor(protected rateLimiterService: RateLimiterService) {} async handle({ payload }: Params): Promise<SendPasswordResetResult> { // ... try { await ipLimiter.consume(ipKey) await emailLimiter.consume(emailKey) const result = await this.#rotateResetTokens(payload.email) if (result) { PasswordResetRequested.dispatch(result.user, result.token) } return { status: 'sent' } } catch (error) { if (error instanceof errors.E_TOO_MANY_REQUESTS) { return { status: 'rate_limited' } } throw error } } async #rotateResetTokens(email: string) { return db.transaction(async (trx) => { const user = await User.query({ client: trx }).where('email', payload.email).first() const { token, hash } = generateTokenAndHash() if (!user) return null await RevokePasswordResetTokens.handle({ user }) await CreatePasswordResetToken.handle({ user, hash }) return { user, token } }) } }Copied!Oh, please don't feel silly for missing it! I don't mind helping in the slightest, I like to link to the docs and even internal code when I know of it just to give more sources and context. I know having additional examples always helps me, and it might help someone else who stumbles across these comments at a later time as well.
My pleasure, @gregory!! Anytime! 🙂
1
-
-
-