Repository navigation
feat(better-auth): implement native consumeOne and incrementOne - #2903
kuldeepsinh19 wants to merge 2 commits into
Conversation
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
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Tip 🎯 Customize your risk assessments
✅ Automatic approvals, with your rules
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. CodeRabbit Verify
|
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) andincrementOne(guarded counter updates, e.g. "decrementremainingonly while it's above 0").@zenstackhq/better-authimplements neither:@better-auth/core1.7.0–1.7.2, password reset throwsAdapter "zenstack" must implement consumeOne(the error in the issue).deleteMany/updateManywith compare-and-swap retries, as @drakeo338 found. That works, but it costs extra round trips. Under contention,incrementOnecan exhaust its retries and throw instead of returning a result.This adds native implementations, modeled on better-auth's own Prisma adapter.
Changes
All in
packages/auth-adapters/better-auth/src/adapter.ts:consumeOne: inside$transaction,findFirst(where), thendeleteMany({ where: { <id>, AND: [where] } }). It returns the row only if exactly that row was deleted, otherwisenull. 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-uniquewhereare never touched.incrementOne: builds{ ...set, [field]: { increment: delta } }. Inside$transactionit finds the row's id, then runsupdateMany({ where: { <id>, AND: [where] }, data }). It returnsnullwhen nothing was updated, otherwise re-reads the row by id. It usesupdateManyrather thanupdateon purpose: on dialects withoutRETURNING(MySQL), ORMupdate()checks the filter in aSELECTand then writes by id only. A guardedupdate()let all 5 concurrent decrements through on MySQL 8.4 in the test below.updateManykeeps the conditions in theUPDATE … WHEREon every dialect.$transactionis safe to call when the adapter already runs on a transaction client (better-auth'stransactionconfig): the ORM reuses the outer transaction.update()is extracted into helpers.getIdFiltergives a flat{ a, b }filter, which the new methods use, sinceupdateMany/deleteManydon't accept compound unique keys.getIdWheregives the unique form forupdate()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 builta_b.peerDependenciesare unchanged. Older better-auth versions never call these methods.NULL + n). The generic fallback treats it as 0.nullinstead of retrying on the other row.mode: 'insensitive'is ignored, as in every other method of this adapter.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, thennullon a second call. Consuming by id. Deleting exactly one of several rows that match a non-uniquewhere. Keeping the row when a guard (expiresAt > now) doesn't match. 5 concurrent consumers get exactly one row. Works insideadapter.transactionand is rolled back with it.incrementOne: decrementing with aremaining > 0guard stops at 0 and then returnsnull.setis 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 returnsnull. Works insideadapter.transactionand is rolled back with it.@@idthroughconsumeOne,incrementOneandupdate()without a unique field.Results (Node 24, pnpm 10.33.0, in containers):
pnpm testpasses on SQLite (34 tests).updateManyfor a guardedupdate()fails the same test on MySQL.a_bnaming inupdate().lint,build(tsc --noEmit+ tsdown) andprettier --checkon the changed files pass.Known gap:
consumeOnekeeping the guard in the DELETE isn't covered by a deterministic test. It only matters when a row stops matching between thefindFirstand theDELETEin 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.