Invitation Acceptance Atomicity#
The organization plugin's /organization/accept-invitation endpoint (crud-invites.ts) implements a two-layer concurrency strategy to safely claim pending invitations and create memberships:
- Atomic claim — a guarded
pending → acceptedupdate ensures only one concurrent acceptor wins. - Transactional membership work — membership creation runs inside
runWithTransaction, with a compensation handler that reverts the claim back topendingif any downstream step fails.
Atomic Status Transition (fromStatus guard)#
The updateInvitation adapter method accepts an optional fromStatus parameter. When present, it appends { field: "status", value: fromStatus } to the WHERE clause before calling adapter.incrementOne:
adapter.incrementOne({
model: "invitation",
where: [{ field: "id", value: invitationId }, { field: "status", value: "pending" }],
increment: {},
set: { status: "accepted" },
})
This compiles to a single-statement guarded update (UPDATE invitation SET status = 'accepted' WHERE id = ? AND status = 'pending' RETURNING *). The first concurrent caller receives the updated row; every racer receives null because the guard no longer matches.
The claim in acceptInvitation gating on a non-null return is the enforcement point:
const acceptedI = await adapter.updateInvitation({
invitationId: ctx.body.invitationId,
status: "accepted",
fromStatus: "pending",
});
if (!acceptedI) {
throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND);
}
Compensation Logic#
After the atomic claim, all membership work — team membership creation, organization membership creation, and session cookie updates — runs inside runWithTransaction . A .catch() on the transaction releases the claim on any failure :
}).catch(async (error) => {
await adapter.updateInvitation({
invitationId: ctx.body.invitationId,
status: "pending", // revert so the invitee can retry
});
throw error;
});
This prevents the invitee from being stranded with an accepted invitation but no membership record.
Drizzle Adapter: Subquery Pattern and PostgreSQL Atomicity Gap#
The Drizzle adapter's incrementOne (and consumeOne) for PostgreSQL uses a select-then-update subquery to enforce single-row semantics :
const targetIds = db
.select({ id: idColumn })
.from(schemaModel)
.where(...clause)
.limit(1); // ← subquery
const updated = await db
.update(schemaModel)
.set(assignments)
.where(inArray(idColumn, targetIds)) // WHERE id IN (subquery)
.returning();
The atomicity gap: Under PostgreSQL's default READ COMMITTED isolation level, the subquery inside WHERE id IN (...) is evaluated as part of the UPDATE statement's snapshot, not as a separately committed step. However, two concurrent UPDATEs targeting the same row can both see the row as matching the guard (status = 'pending') and both succeed — PostgreSQL does not guarantee that a correlated subquery produces the same first-writer-wins serialization as a plain UPDATE ... WHERE status = 'pending'. Practically: the LIMIT 1 subquery does not add row-level locking, so both concurrent callers can select the same id and both proceed to update, violating the contract.
MySQL is handled differently: the adapter wraps the operation in an explicit SELECT ... FOR UPDATE transaction , serializing concurrent claimants via row-level locking. No equivalent locking is applied on the PostgreSQL path.
The safe pattern for PostgreSQL is a single UPDATE ... WHERE ... RETURNING without a subquery — the WHERE clause itself acts as the compare-and-swap guard (only rows currently matching both id and status are touched), and PostgreSQL's row-level concurrency ensures exactly one writer wins per row per statement.
Related PRs#
| PR | Change |
|---|---|
| #9993 — fix: make single-use credentials, counters, and replay markers atomic | Introduced fromStatus guard and the .catch compensation block |
| #10086 — fix: make guarded state transitions portable on Prisma | Replaced multi-clause adapter.update() with adapter.incrementOne() to fix portability on Prisma |
Key Files#
| File | Role |
|---|---|
packages/better-auth/src/plugins/organization/routes/crud-invites.ts | acceptInvitation endpoint — claim, transaction, and compensation |
packages/better-auth/src/plugins/organization/adapter.ts | updateInvitation with fromStatus guard via incrementOne |
packages/drizzle-adapter/src/drizzle-adapter.ts | Drizzle incrementOne/consumeOne — subquery pattern and MySQL FOR UPDATE path |