You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(better-auth): support compound where in update and delete - #2901
@zenstackhq/better-auth converts a where with two or more conditions into { AND: [...] } and passes it to ORM update()/delete(), which require a unique field at the top level of where. better-auth issues compound wheres in normal flows, so these calls fail with:
Invalid update args for model "SsoProvider": Validation error: At least one unique field or field set must be set at "where"
@better-auth/sso >= 1.7 locks the provider row with update({ where: [{ id }, { providerId }] }) before updating or deleting a provider and during IdP account linking, as described by @adesege in the issue (thanks for the analysis).
The device authorization verify claim in better-auth 1.6.11+ (within the ^1.3.0 peer range) updates with [{ id }, { status: 'pending' }, { userId: null }] and expects null when the claim is lost. better-auth 1.7 moved this to incrementOne, which goes through updateMany and is not affected.
delete() with a compound where hit the same validation error, but the bare catch {} swallowed it, so the row was silently kept.
Changes
All in packages/auth-adapters/better-auth/src/adapter.ts, modeled on better-auth's own Prisma adapter:
update()/delete() lift AND-ed equality conditions to the top level of where and keep the other conditions (AND/OR) as filters. The ORM accepts a unique field plus extra filters and applies all of them.
With a single-field unique key at the top level (from QueryUtils.getUniqueFields), the row is updated/deleted directly. A missing row makes update() return null, as better-auth's adapter contract expects, and is ignored by delete().
Without a unique key, update() finds a matching row and updates it by id, keeping the original conditions as a guard, so it changes at most one row and returns null if the row no longer matches. This uses only findFirst/update, so it works on SQLite, PostgreSQL and MySQL (updateManyAndReturn isn't available on MySQL). delete() uses deleteMany().
delete() now only ignores "not found"; other errors (e.g. access policy or foreign key errors) propagate instead of being swallowed. better-auth's Prisma adapter behaves the same way.
"Not found" is detected by ORMErrorReason.NOT_FOUND on the error's reason, not instanceof, in case an app ends up with two copies of the ORM.
The three copies of the condition conversion in convertWhereClause now share a small helper; behavior is unchanged.
Compound @@unique/@@id keys and mode: 'insensitive' are not handled differently from before: compound keys take the non-unique path, and mode was already ignored by the adapter.
Validation
New test/adapter-db.test.ts runs the adapter through better-auth's createAdapterFactory against a real database from @zenstackhq/testtools (added as a devDependency, as in packages/server; the lockfile change is the importer entry only). It covers the SSO row lock (also inside a transaction), the device-authorization compare-and-swap claim, updates without a unique field (single row, filtered field changed by the update), OR conditions, missing rows, and compound/non-unique deletes.
Without the change, 10 of the 12 new tests fail (3 of 3 runs) with the errors from the issue; with it all 12 pass (3 of 3 runs).
pnpm --filter @zenstackhq/better-auth lint, build (tsc --noEmit + tsdown) and prettier --check on the changed files pass.
Ran on SQLite only (Node 24, pnpm 10.33.0, in a container). The PostgreSQL and MySQL legs of the CI matrix were not run locally. In that container, test/cli-generate.test.ts fails the same way with and without this change, because pnpm better-auth can't be resolved from the test directory there.
Review notes
Before opening this PR, the diff, the issue and the test results went through an independent review. It found one blocker: the first version used updateManyAndReturn for updates without a unique key, and that throws on MySQL, which the CI matrix covers. That path now finds the row with findFirst and updates it by id with the original conditions as a guard, which works on all three providers and changes at most one row. The review also led to:
"not found" detection by reason instead of instanceof;
a test for the transaction path that @better-auth/sso uses;
a test that a non-unique update changes a single row.
CodeRabbit reviewed the PR and had no actionable comments.
Re-checked on 2026-11-03: there are no merge conflicts with dev. The workflow runs are waiting for maintainer approval (first-time contributor).
This change was written with help from an AI coding assistant (Claude Code).
Summary by CodeRabbit
Bug Fixes
Better Auth adapter updates now handle compound filters, OR conditions, and records without unique filters more reliably, including within transactions.
Updates for records that do not exist now return null instead of failing.
Deletes now support compound and non-unique filters. Errors for missing records are handled consistently, while other deletion errors are no longer silently suppressed.
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()`.
Fixeszenstackhq#2694
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
Walkthrough
The Better Auth adapter now converts compound where conditions for unique operations. Updates use a row lookup when the filter is not unique, and deletes use deleteMany for non-unique filters. Database tests cover compound filters, missing rows, transactions, and compare-and-swap updates.
Changes: Better Auth filter handling
Layer / File(s)
Summary
Where conversion and unique selection packages/auth-adapters/better-auth/src/adapter.ts
Condition conversion is shared across where clauses. Equality conditions can be lifted into a unique-operation filter when they match a declared single-field unique key.
Update and delete behavior packages/auth-adapters/better-auth/src/adapter.ts, packages/auth-adapters/better-auth/test/adapter-db.test.ts, packages/auth-adapters/better-auth/package.json
Updates with non-unique filters find a matching row, then update by its ID fields with the original filter as a guard. Deletes with non-unique filters use deleteMany; unique deletes suppress only ORM not-found errors. Database tests cover these behaviors, and the package adds @zenstackhq/testtools as a development dependency.
zenstackhq/zenstack#2884: The reported MySQL behavior for zero-match deleteMany() may affect the adapter’s non-unique delete path.
Possibly related PRs
zenstackhq/zenstack#2895: Updates Better Auth to 1.7.12, which emits compound filters handled by this adapter change.
Suggested reviewers:ymc9
Merge Risk: Minimal · up to 33be9
No actionable merge-blocking issue remains. The changed filter and delete behavior is consistent with the inspected contracts.
Pre-merge checks: 5
Check name
Status
Explanation
Description Check
Check skipped - CodeRabbit’s high-level summary is enabled.
Title check
The title clearly and concisely describes the main change: support for compound where conditions in Better Auth update and delete operations.
Linked Issues check
Issue [#2694] requires compound where support in the Better Auth adapter update() path. The change lifts equality conditions to a top-level unique selector when possible. Otherwise, update() use…
Out of Scope Changes check
The adapter refactor, delete() handling, SSO row-lock coverage, device-claim coverage, and database test dependency support the same compound-where compatibility problem described in [#2694] and t…
Docstring Coverage
No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Full details: Linked Issues check
Explanation
Issue [#2694] requires compound where support in the Better Auth adapter update() path. The change lifts equality conditions to a top-level unique selector when possible. Otherwise, update() uses findFirst and an ID-based update() with the original conditions as a guard. The new database tests cover the device-authorization compare-and-swap claim, compound filters, missing rows, and transaction use. The implementation satisfies the required behavior without requiring the issue's suggested updateMany() strategy.
Full details: Out of Scope Changes check
Explanation
The adapter refactor, delete() handling, SSO row-lock coverage, device-claim coverage, and database test dependency support the same compound-where compatibility problem described in [#2694] and the PR intent. The changes do not show an unrelated feature or unrelated code path.
Full details: Docstring Coverage
Explanation
No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 2 files. (1 skipped: 1 unsupported.)
Finishing Touches
Generate unit tests (beta)
Create a new PR
Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Warning
Some tools did not complete. Review the errors below.
ESLint
If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.
packages/auth-adapters/better-auth/package.json
ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.
packages/auth-adapters/better-auth/src/adapter.ts
ESLint skipped: the matched ESLint configuration already failed (missing-dependency).
Another real-world case this fixes: Better Auth's organization plugin with dynamic access control. deleteRole always calls adapter.delete on organizationRole with [organizationId, role] (by roleName) or [organizationId, id] (by roleId). On @zenstackhq/better-auth 3.10.0 both become { AND: [...] }, the ORM rejects them for lacking a top-level unique field, the bare catch {} swallows the error, and the endpoint returns { success: true } while the row stays. Reproduced against a real app (create two roles, delete one by name and one by id, list roles afterwards: both still present). From reading this PR, the roleName form falls back to deleteMany and the roleId form deletes by the lifted unique id, so both should be covered.
I haven't run the PR branch yet. Happy to test it against this scenario if useful.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #2694.
@zenstackhq/better-authconverts awherewith two or more conditions into{ AND: [...] }and passes it to ORMupdate()/delete(), which require a unique field at the top level ofwhere. better-auth issues compound wheres in normal flows, so these calls fail with:@better-auth/sso>= 1.7 locks the provider row withupdate({ where: [{ id }, { providerId }] })before updating or deleting a provider and during IdP account linking, as described by @adesege in the issue (thanks for the analysis).^1.3.0peer range) updates with[{ id }, { status: 'pending' }, { userId: null }]and expectsnullwhen the claim is lost. better-auth 1.7 moved this toincrementOne, which goes throughupdateManyand is not affected.delete()with a compound where hit the same validation error, but the barecatch {}swallowed it, so the row was silently kept.Changes
All in
packages/auth-adapters/better-auth/src/adapter.ts, modeled on better-auth's own Prisma adapter:update()/delete()lift AND-ed equality conditions to the top level ofwhereand keep the other conditions (AND/OR) as filters. The ORM accepts a unique field plus extra filters and applies all of them.QueryUtils.getUniqueFields), the row is updated/deleted directly. A missing row makesupdate()returnnull, as better-auth's adapter contract expects, and is ignored bydelete().update()finds a matching row and updates it by id, keeping the original conditions as a guard, so it changes at most one row and returnsnullif the row no longer matches. This uses onlyfindFirst/update, so it works on SQLite, PostgreSQL and MySQL (updateManyAndReturnisn't available on MySQL).delete()usesdeleteMany().delete()now only ignores "not found"; other errors (e.g. access policy or foreign key errors) propagate instead of being swallowed. better-auth's Prisma adapter behaves the same way.ORMErrorReason.NOT_FOUNDon the error'sreason, notinstanceof, in case an app ends up with two copies of the ORM.convertWhereClausenow share a small helper; behavior is unchanged.@@unique/@@idkeys andmode: 'insensitive'are not handled differently from before: compound keys take the non-unique path, andmodewas already ignored by the adapter.Validation
test/adapter-db.test.tsruns the adapter through better-auth'screateAdapterFactoryagainst a real database from@zenstackhq/testtools(added as a devDependency, as inpackages/server; the lockfile change is the importer entry only). It covers the SSO row lock (also inside a transaction), the device-authorization compare-and-swap claim, updates without a unique field (single row, filtered field changed by the update), OR conditions, missing rows, and compound/non-unique deletes.pnpm --filter @zenstackhq/better-auth lint,build(tsc --noEmit+ tsdown) andprettier --checkon the changed files pass.test/cli-generate.test.tsfails the same way with and without this change, becausepnpm better-authcan't be resolved from the test directory there.Review notes
updateManyAndReturnfor updates without a unique key, and that throws on MySQL, which the CI matrix covers. That path now finds the row withfindFirstand updates it by id with the original conditions as a guard, which works on all three providers and changes at most one row. The review also led to:reasoninstead ofinstanceof;@better-auth/ssouses;dev. The workflow runs are waiting for maintainer approval (first-time contributor).This change was written with help from an AI coding assistant (Claude Code).
Summary by CodeRabbit
nullinstead of failing.