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
- Use
zenstackAdapter as the better-auth database.
- Enable the
deviceAuthorization plugin.
- Sign in, then open
GET /device?user_code=... (verify step).
- 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().
Summary
@zenstackhq/better-auth'supdate()forwards a compound (AND)wherestraight to ZenStack ORMupdate(), which requires a unique field at the top level ofwhere. Any better-auth flow that updates by a multi-conditionwheretherefore fails before SQL runs. This is now hit in normal usage by better-auth'sdeviceAuthorizationplugin.Environment
@zenstackhq/better-auth: 3.3.3 and 3.7.2 (latest) — same behaviorbetter-auth: 1.6.12 (regression triggered by >= 1.6.11)Repro
zenstackAdapteras the better-authdatabase.deviceAuthorizationplugin.GET /device?user_code=...(verify step).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: [...] }(zenstack/packages/auth-adapters/better-auth/src/adapter.ts
Lines 47 to 90 in f41a1f6
update()callsmodelDb.update({ where })unconditionally (zenstack/packages/auth-adapters/better-auth/src/adapter.ts
Lines 146 to 152 in f41a1f6
update()rejects the nested unique.Suggested fix
In
update(), when the convertedwhereis not a single top-level unique selector, fall back to the existingupdateMany()(then optionally re-read by the unique field) — mirroring how better-auth's own Prisma adapter handles non-unique updatewhere. The adapter already implementsupdateMany()right belowupdate().