Skip to content

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

@beshkenadze

Description

@beshkenadze

Summary

@zenstackhq/better-auth's update() forwards a compound (AND) where straight to ZenStack ORM update(), which requires a unique field at the top level of where. Any better-auth flow that updates by a multi-condition where therefore fails before SQL runs. This is now hit in normal usage by better-auth's deviceAuthorization plugin.

Environment

  • @zenstackhq/better-auth: 3.3.3 and 3.7.2 (latest) — same behavior
  • better-auth: 1.6.12 (regression triggered by >= 1.6.11)
  • ZenStack ORM 3.x, provider sqlite/postgresql

Repro

  1. Use zenstackAdapter as the better-auth database.
  2. Enable the deviceAuthorization plugin.
  3. Sign in, then open GET /device?user_code=... (verify step).
  4. Error:
    Invalid update args for model "DeviceCode": Validation error:
    At least one unique field or field set must be set at "where"
    

Root cause

better-auth >=1.6.11 added a verify-time ownership claim that updates with a compound where { id, status: "pending", userId: null } (https://github.com/better-auth/better-auth/blob/a6f38c72ee3423ae80b0595fec3b4a61158c374d/packages/better-auth/src/plugins/device-authorization/routes.ts#L144-L154).
The adapter's convertWhereClause() turns >=2 conditions into { AND: [...] } (

const convertWhereClause = (model: string, where?: Where[]): any => {
if (!where || !where.length) return {};
if (where.length === 1) {
const w = where[0]!;
if (!w) {
throw new BetterAuthError('Invalid where clause');
}
return {
[getFieldName({ model, field: w.field })]:
w.operator === 'eq' || !w.operator
? w.value
: {
[operatorToORMOperator(w.operator)]: w.value,
},
};
}
const and = where.filter((w) => w.connector === 'AND' || !w.connector);
const or = where.filter((w) => w.connector === 'OR');
const andClause = and.map((w) => {
return {
[getFieldName({ model, field: w.field })]:
w.operator === 'eq' || !w.operator
? w.value
: {
[operatorToORMOperator(w.operator)]: w.value,
},
};
});
const orClause = or.map((w) => {
return {
[getFieldName({ model, field: w.field })]:
w.operator === 'eq' || !w.operator
? w.value
: {
[operatorToORMOperator(w.operator)]: w.value,
},
};
});
return {
...(andClause.length ? { AND: andClause } : {}),
...(orClause.length ? { OR: orClause } : {}),
};
};
), and update() calls modelDb.update({ where }) unconditionally (
async update({ model, where, update }): Promise<any> {
const modelDb = requireModelDb(db, model);
const whereClause = convertWhereClause(model, where);
return await modelDb.update({
where: whereClause,
data: update as UpdateInput<SchemaDef, GetModels<SchemaDef>, any>,
});
). ORM update() rejects the nested unique.

Suggested fix

In update(), when the converted where is not a single top-level unique selector, fall back to the existing updateMany() (then optionally re-read by the unique field) — mirroring how better-auth's own Prisma adapter handles non-unique update where. The adapter already implements updateMany() right below update().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions