Skip to content

fix(better-auth): support compound where in update and delete - #2901

Open
kuldeepsinh19 wants to merge 1 commit into
zenstackhq:devfrom
kuldeepsinh19:fix/2694-better-auth-compound-where
Open

kuldeepsinh19 wants to merge 1 commit into
zenstackhq:devfrom
kuldeepsinh19:fix/2694-better-auth-compound-where

Conversation

@kuldeepsinh19

@kuldeepsinh19 kuldeepsinh19 commented Nov 2, 2026 •

Copy link
Copy Markdown

Summary

Fixes #2694.

@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()`.

Fixes zenstackhq#2694
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between d44e489 and 33be9fa.

Files ignored due to path filters: 1
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
Files selected for processing: 3
  • packages/auth-adapters/better-auth/package.json
  • packages/auth-adapters/better-auth/src/adapter.ts
  • packages/auth-adapters/better-auth/test/adapter-db.test.ts

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.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Possibly related issues

  • zenstackhq/zenstack#2884: The reported MySQL behavior for zero-match deleteMany() may affect the adapter’s non-unique delete path.

Possibly related PRs

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: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: support for compound where conditions in Better Auth update and delete operations.
Linked Issues check Passed 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 Passed 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 Passed 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).

packages/auth-adapters/better-auth/test/adapter-db.test.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

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

@rasyidly

rasyidly commented Nov 3, 2026

Copy link
Copy Markdown

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
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.

better-auth adapter: update() with compound where fails (deviceAuthorization verify-claim) — no updateMany fallback

2 participants