Skip to Content
Delmare Digital

Atomic operations

How the adapter implements Better Auth's consumeOne and incrementOne, the atomic Postgres SQL path, the atomicWrites policy and its limits.

Better Auth 1.7 requires every database adapter to implement two atomic primitives, and calls them on paths you are almost certainly using:

PrimitiveUsed by
consumeOneSingle-use credentials: email verification, password reset, magic links, email OTP, device-authorization codes
incrementOneGuarded counters: API-key quota and rate limits, two-factor backup codes, team member counts

Postgres: one guarded SQL statement

On Postgres, eligible collections and supported predicates run as one guarded SQL statement over Payload's existing Drizzle connection:

  • consumeOne uses DELETE … RETURNING, returning the row only to the caller whose statement removed it.
  • incrementOne uses UPDATE … SET counter = COALESCE(counter, 0) + delta … RETURNING, so concurrent increments accumulate without a read-then-write race.

Both statements select at most one identity and re-assert the caller's guard on the outer write, where Postgres rechecks it after waiting for a concurrent writer. A guard miss returns null. No migration is required.

Since 0.14.2. Earlier releases used the Local API path on every database.

The atomicWrites policy

src/payload.config.ts
payloadAdapter({
  payloadClient: payload,
  adapterConfig: { atomicWrites: 'auto' }, // default
})
ModePath for incrementOne and consumeOne
'auto' (default)Atomic Postgres when the sanitized collection has no trash, no versions/drafts, and no collection or field hooks other than field beforeDuplicate. Nested fields and read hooks are included in the check; empty hook arrays are allowed. Each refusal warns once per collection and reason, then uses the Local API.
'always'Atomic Postgres even with hooks, after you have reviewed them. Throws if an otherwise eligible collection or request cannot run atomically. Trash and versions/drafts still use the Local API, with a warning once per collection and reason.
'never'Always the Local API.

The atomic path does not run Payload hooks

This includes validation, change, delete, operation and read hooks. For a hook-only refusal, review the hooks before choosing 'always' to bypass them.

  • Payload's default beforeDuplicate hooks on unique fields don't disqualify a collection; they never run on update or delete.
  • On the Payload 4 line, generated collections set authorship: false to keep injected createdBy/updatedBy hooks from disabling atomic writes. Re-enabling authorship through customizeCollection restores those hooks and the default policy's Local API fallback. See the Payload 4 upgrade note.
  • Admin baseFilter / baseListFilter don't apply to these Local API operations and don't disqualify atomic writes.
  • Virtual, join and ui fields have no stored columns and are ignored in the column checks.
  • The policy inspects the sanitized collection config once per collection and caches the decision for the adapter instance, so configure collections before using the adapter.

Logging

  • Policy refusals (in 'auto' and 'always') always console.warn once per collection and distinct reason.
  • SQL compatibility failures (missing connection, table or column metadata; unsupported stored fields or writes; untranslatable predicates) always console.error once per collection and distinct reason. 'auto' then uses the Local API; 'always' throws.
  • With enableDebugLogs: true, the adapter also logs the first atomic execution and first fallback per collection.

Under 'always', a predicate shape introduced by a future Better Auth release that the adapter can't translate will throw until the adapter is patched. That is why 'auto' is the default.

Supported shapes

Supported predicates are scalar eq, ne, lt, lte, gt, gte and scalar-array in, with the usual AND/OR grouping and explicit NULL semantics. Nested paths, JSON predicates, insensitive mode and other operators are not supported. Unsupported stored shapes (such as localized or has-many fields) are not supported either. Both log an error and fall back in 'auto', or throw in 'always'.

Malformed IDs fail closed. Invalid UUIDs and non-numeric serial primary or relationship IDs never match, including for ne, so an invalid exclusion can't broaden a credential write to every non-null row. This is stricter than Payload's query builder, which validates primary-key UUIDs but passes relationship UUIDs through. Explicit null keeps its existing semantics.

Local API fallback and its limit

Other databases (MongoDB, SQLite), and any collection or request that falls back, use the Local API:

  • consumeOne reads the row, then deletes it by id. The row is returned only if this call's delete removed it; if a concurrent caller got there first, Payload raises a 404 and the adapter returns null.
  • incrementOne reads the row, then writes with a guard that re-asserts Better Auth's precondition (for example remaining > 0) and the counter values the write was computed from. A racing writer's update matches no row, so the loser re-reads and retries (up to five attempts) instead of overwriting the winner.

The fallback narrows the race; it does not remove it

Payload resolves a where by finding rows and then mutating them, so a window remains between its internal read and write. Under heavy concurrency on the same row, a counter update can be lost or a token consumed twice. If you need strict cross-process guarantees for API-key quota on this path, enforce them in the database with a CHECK constraint or a unique index.

Dependency

drizzle-orm is a regular dependency pinned to 0.45.2, matching Payload 3.92.1, and installs with the package. Static imports keep the atomic SQL path in production Next.js, Vercel and standalone bundles without runtime adapter discovery. A test compares the pin with the installed dev Payload, and a daily canary compares it with the latest published @payloadcms/drizzle requirement and opens an issue on drift.

CI runs a real Postgres 17 integration suite for both UUID and serial IDs: concurrent increments, single-use consumption, OAuth refresh-token guards, malformed identifiers, row-shape parity, and Better Auth's ten-failure TOTP lockout.

Edit this page on GitHub

On this page