Skip to content

feat(better-auth): implement native consumeOne and incrementOne - #2903

Draft
kuldeepsinh19 wants to merge 2 commits into
zenstackhq:devfrom
kuldeepsinh19:feat/2820-better-auth-consume-one
Draft

kuldeepsinh19 wants to merge 2 commits into
zenstackhq:devfrom
kuldeepsinh19:feat/2820-better-auth-consume-one

Conversation

@kuldeepsinh19

Copy link
Copy Markdown

Summary

Closes #2820.

better-auth 1.7 added two atomic adapter methods: consumeOne (delete-and-return a single-use row: password reset and email verification tokens, device codes, OAuth codes) and incrementOne (guarded counter updates, e.g. "decrement remaining only while it's above 0"). @zenstackhq/better-auth implements neither:

  • With @better-auth/core 1.7.0–1.7.2, password reset throws Adapter "zenstack" must implement consumeOne (the error in the issue).
  • From 1.7.3, better-auth falls back to a generic read + snapshot-guarded deleteMany/updateMany with compare-and-swap retries, as @drakeo338 found. That works, but it costs extra round trips. Under contention, incrementOne can exhaust its retries and throw instead of returning a result.

This adds native implementations, modeled on better-auth's own Prisma adapter.

Stacked on #2901. It reuses the helpers and the DB test file from that PR. Only the last commit is new here; I'll rebase onto dev once #2901 is merged.

Changes

All in packages/auth-adapters/better-auth/src/adapter.ts:

  • consumeOne: inside $transaction, findFirst(where), then deleteMany({ where: { <id>, AND: [where] } }). It returns the row only if exactly that row was deleted, otherwise null. Keeping the original conditions in the DELETE means a row that stopped matching (e.g. expired) is never consumed. Deleting by id means other rows matching a non-unique where are never touched.
  • incrementOne: builds { ...set, [field]: { increment: delta } }. Inside $transaction it finds the row's id, then runs updateMany({ where: { <id>, AND: [where] }, data }). It returns null when nothing was updated, otherwise re-reads the row by id. It uses updateMany rather than update on purpose: on dialects without RETURNING (MySQL), ORM update() checks the filter in a SELECT and then writes by id only. A guarded update() let all 5 concurrent decrements through on MySQL 8.4 in the test below. updateMany keeps the conditions in the UPDATE … WHERE on every dialect.
  • $transaction is safe to call when the adapter already runs on a transaction client (better-auth's transaction config): the ORM reuses the outer transaction.
  • The "id → where" code from update() is extracted into helpers. getIdFilter gives a flat { a, b } filter, which the new methods use, since updateMany/deleteMany don't accept compound unique keys. getIdWhere gives the unique form for update() and now takes the compound key's real name from the schema, so a named @@id([a, b], name: "x") works too. Before, it always built a_b.
  • peerDependencies are unchanged. Older better-auth versions never call these methods.
  • Kept the same as better-auth's Prisma adapter:
    • A null counter stays null (SQL NULL + n). The generic fallback treats it as 0.
    • If two callers race for the same row while a second matching row exists, the loser gets null instead of retrying on the other row.
    • mode: 'insensitive' is ignored, as in every other method of this adapter.
  • The atomicity above holds for plain models. For polymorphic (delegate) models the ORM resolves ids before writing. better-auth's tables are never polymorphic.

Validation

New tests in test/adapter-db.test.ts (real database via @zenstackhq/testtools, going through better-auth's adapter factory):

  • consumeOne: returns the row, then null on a second call. Consuming by id. Deleting exactly one of several rows that match a non-unique where. Keeping the row when a guard (expiresAt > now) doesn't match. 5 concurrent consumers get exactly one row. Works inside adapter.transaction and is rolled back with it.
  • incrementOne: decrementing with a remaining > 0 guard stops at 0 and then returns null. set is applied in the same write. 5 concurrent decrements of a counter at 2 give exactly 2 successes, and the counter ends at 0. Increment by id. Only one of several matching rows is updated. A missing row returns null. Works inside adapter.transaction and is rolled back with it.
  • Both methods: a test checks that the adapter's own method ran, not better-auth's fallback.
  • Compound ids: a model with a named @@id through consumeOne, incrementOne and update() without a unique field.

Results (Node 24, pnpm 10.33.0, in containers):

  • All 28 tests in the file pass on SQLite, PostgreSQL 17 and MySQL 8.4. The package's full pnpm test passes on SQLite (34 tests).
  • Mutation checks on PostgreSQL and MySQL:
    • Removing the guard from the write lets all 5 concurrent decrements succeed (counter goes to −3); the concurrency test fails.
    • Swapping updateMany for a guarded update() fails the same test on MySQL.
    • Removing the two new methods (so better-auth's fallback runs) fails the native-path test.
    • Going back to the compound key form in the new methods fails the compound-id tests. So does the old a_b naming in update().
  • lint, build (tsc --noEmit + tsdown) and prettier --check on the changed files pass.

Known gap: consumeOne keeping the guard in the DELETE isn't covered by a deterministic test. It only matters when a row stops matching between the findFirst and the DELETE in the same transaction. The by-id delete already prevents double consumption, so the concurrency test passes either way.

This change was written with help from an AI coding assistant (Claude Code). I've reviewed every line and can explain any of it.

The adapter turned a where with two or more conditions into
`{ AND: [...] }` and passed it to ORM `update()`/`delete()`, which need a
unique field at the top level. better-auth updates with compound wheres
(e.g. @better-auth/sso row locks, device authorization claims on 1.6.x),
so those calls failed with "At least one unique field or field set must
be set at where", and compound deletes were silently skipped because
`delete()` swallowed every error.

`update()`/`delete()` now lift AND-ed equality conditions to the top
level and keep the rest as filters. When a single-field unique key is
present the row is updated/deleted directly; a missing row returns
`null` from `update()` (better-auth's contract) and is ignored by
`delete()`, while other errors propagate. Without a unique key,
`update()` finds a matching row and updates it by id with the original
conditions as a guard, and `delete()` uses `deleteMany()`.

Fixes zenstackhq#2694
better-auth 1.7 added atomic consumeOne (single-use token consumption) and
incrementOne (guarded counter updates) adapter methods. Without them,
@better-auth/core 1.7.0-1.7.2 throws on password reset, and later versions
fall back to a generic read + compare-and-swap path.

Both methods locate the row and then write by id with the original
conditions kept in the DELETE/UPDATE filter, inside a transaction.
incrementOne uses updateMany because ORM update() drops the filter on
dialects without RETURNING (MySQL).

Also make the id helpers work with compound ids, including a named @@id.

Closes zenstackhq#2820
@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

consumeOne method not implemented — password reset broken with better-auth >=1.7.0

1 participant