fix(sprint-5/etapa-4d): mover queries DB fuera de onAuthStateChange — corta deadlock lockAcquired permanente
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

El callback de onAuthStateChange en GoTrueClient adquiere un lock interno (lockAcquired=true).
Hacer await supabase.from('users').select() dentro de ese callback genera un deadlock circular:
el select necesita getSession() → _acquireLock() → lockAcquired=true → espera al callback →
callback espera al select → deadlock permanente. Toda query posterior a Supabase cuelga.

Fix: el callback de onAuthStateChange pasa a ser síncrono y solo llama buildAuthUser() (sin
queries). El upsert y la lectura de company/name se mueven a un useEffect([user?.id]) en
AuthContext, que corre FUERA del lock de auth y solo se dispara al cambiar de usuario real
(distinto id), no en cada updateUser() local.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 01:28:26 -03:00
parent dd361b1b12
commit 496c227c88
2 changed files with 65 additions and 47 deletions

View File

@@ -72,6 +72,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => unsubscribe() return () => unsubscribe()
}, []) }, [])
// Sincronización con tabla users FUERA del lock de auth.
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
// user?.id es primitivo estable: no cambia cuando se edita nombre o empresa.
useEffect(() => {
if (!user?.id) return
;(async () => {
try {
// Upsert idempotente: crea la fila si no existe (primer login),
// ignoraDuplicates evita sobreescribir name/company editados posteriormente.
await authService.upsertUserOnLogin(user)
// Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google).
const profile = await authService.syncProfileFromDB(user.id)
if (profile) {
setUser((prev) =>
prev?.id === user.id
? { ...prev, company: profile.company, name: profile.name ?? prev.name }
: prev
)
}
} catch (err) {
console.error('Error al sincronizar perfil:', err)
}
})()
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const signIn = useCallback(() => authService.signInWithGoogle(), []) const signIn = useCallback(() => authService.signInWithGoogle(), [])
const signOut = useCallback(async () => { const signOut = useCallback(async () => {

View File

@@ -32,62 +32,24 @@ export class SupabaseAuthService implements AuthService {
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void { onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => { (event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
try { try {
if (!session?.user) { if (!session?.user) {
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión // INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout. // (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
// Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc). // Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc).
if (event !== 'INITIAL_SESSION') { if (event !== 'INITIAL_SESSION') callback(null)
callback(null)
}
return return
} }
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return
// Solo procesar eventos genuinos de inicio de sesión. // Llamar callback inmediatamente con datos básicos del token OAuth.
// TOKEN_REFRESHED, USER_UPDATED y similares son eventos internos — ignorarlos // NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213).
// previene sobreescribir ediciones locales (nombre, empresa) con datos re-leídos de DB. // El upsert y la lectura de company/name ocurren en AuthContext fuera del lock.
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') { callback(this.buildAuthUser(session.user))
return
}
if (event === 'SIGNED_IN') {
// Primer login: upsert del usuario en la tabla users
const email = session.user.email ?? ''
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
const { error: upsertError } = await supabase.from('users').upsert(
{
id: session.user.id,
name: session.user.user_metadata?.full_name ?? email,
avatar_url: session.user.user_metadata?.avatar_url ?? null,
platform_role: role,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
}
// Leer company y name editado desde la tabla users (pueden diferir del user_metadata de Google)
const { data: userRow } = await supabase
.from('users')
.select('company, name')
.eq('id', session.user.id)
.maybeSingle()
const base = this.buildAuthUser(session.user)
const user: AuthUser = {
...base,
company: (userRow?.company as string | null) ?? undefined,
name: (userRow?.name as string | null) ?? base.name,
}
callback(user)
} catch (err) { } catch (err) {
console.error('Error en onAuthStateChange:', err) console.error('Error en onAuthStateChange:', err)
if (session?.user) { if (session?.user) callback(this.buildAuthUser(session.user))
callback(this.buildAuthUser(session.user)) else if (event !== 'INITIAL_SESSION') callback(null)
} else if (event !== 'INITIAL_SESSION') {
callback(null)
}
} }
} }
) )
@@ -95,6 +57,36 @@ export class SupabaseAuthService implements AuthService {
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
} }
// Leer company y name editados desde la tabla users (pueden diferir del user_metadata de Google).
// Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
async syncProfileFromDB(userId: string): Promise<Pick<AuthUser, 'name' | 'company'> | null> {
const { data } = await supabase
.from('users')
.select('company, name')
.eq('id', userId)
.maybeSingle()
if (!data) return null
return {
name: (data.name as string | null) ?? undefined as unknown as string,
company: (data.company as string | null) ?? undefined,
}
}
// Upsert idempotente al login: crea la fila si no existe, ignora si ya existe.
// Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
async upsertUserOnLogin(user: AuthUser): Promise<void> {
const { error } = await supabase.from('users').upsert(
{
id: user.id,
name: user.name,
avatar_url: user.avatarUrl ?? null,
platform_role: user.platformRole,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
if (error) console.error('Error al crear usuario en DB:', error)
}
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB. // Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
// El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN). // El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN).
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser { private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {